diff --git a/app/utils/ai-resource-registry.js b/app/utils/ai-resource-registry.js index a274bbfd17..01de8a8c71 100644 --- a/app/utils/ai-resource-registry.js +++ b/app/utils/ai-resource-registry.js @@ -30,7 +30,8 @@ export const RESOURCE_FAMILIES = Object.freeze([ family('catalog', [catalog('catalogTemplate', ['name', 'description', 'catalogId', 'templateBase', 'templateKey', 'catalogScope', 'defaultVersion', 'maintainer', 'license']), catalog('catalogTemplateVersion', ['version', 'revision', 'minimumRancherVersion', 'maximumRancherVersion', 'upgradeFrom'], {type: 'catalogTemplate', field: 'templateId', required: true})], 'pending-adapter', 'Template and version metadata use the dedicated authorized Catalog adapter. Configured defaultVersion and version bounds do not establish compatibility. Typed stack launch needs target-scope read, create and update permissions and a dedicated human-approved plan. External volume references (external=true), Catalog administration and upgrades still need adapters.'), - family('certificates', [project('certificate', 'certificates', ['CN', 'issuer', 'issuedAt', 'expiresAt', 'algorithm', 'keySize'])]), + family('certificates', [resource('certificate', 'certificates', 'project', ['CN', 'issuer', 'issuedAt', 'expiresAt', 'algorithm', 'keySize'], null, + ['Typed changes support name/description only. PEM, private keys, certificate chains, issuance, renewal and removal remain in the normal human certificate UI.'])]), family('container', [project('container', 'containers', ['healthState', 'memory', 'cpuShares', 'startCount'])]), family('environment', [global('project', 'projects', ['orchestration', 'virtualMachine']), global('projectTemplate', 'projectTemplates')]), family('global', [], 'pending-adapter', 'Shared navigation and UI controls need semantic UI helpers, not a synthetic resource endpoint.'), @@ -66,6 +67,7 @@ export function resourceDescriptors() { return RESOURCE_FAMILIES.flatMap((entry) const action = (fields = [], required = [], minFields = 0) => Object.freeze({fields: Object.freeze(fields), required: Object.freeze(required), minFields}); const metadata = action(['name', 'description'], [], 1); export const RESOURCE_CHANGES = Object.freeze({ + certificate: Object.freeze({update_metadata: metadata}), stack: Object.freeze({start: action(), stop: action(), remove: action(), archive: action(), restore: action(), create: action(['name', 'description'], ['name']), create_from_catalog: action(['name', 'description', 'startOnCreate', 'answers'], ['name']), update_metadata: metadata}), service: Object.freeze({start: action(), stop: action(), restart: action(), remove: action(), archive: action(), restore: action(), scale: action(['scale'], ['scale']), diff --git a/app/utils/ai-resource-review-labels.js b/app/utils/ai-resource-review-labels.js index 688b08d846..6c89999352 100644 --- a/app/utils/ai-resource-review-labels.js +++ b/app/utils/ai-resource-review-labels.js @@ -1,4 +1,4 @@ -const TYPES = {dnsService: 'servicePage.type.dnsservice', externalService: 'servicePage.type.externalservice', stack: 'apiKeyAccess.selector.types.stack', service: 'apiKeyAccess.selector.types.service', container: 'apiKeyAccess.selector.types.container'}; +const TYPES = {certificate: 'certificatesPage.index.header', dnsService: 'servicePage.type.dnsservice', externalService: 'servicePage.type.externalservice', stack: 'apiKeyAccess.selector.types.stack', service: 'apiKeyAccess.selector.types.service', container: 'apiKeyAccess.selector.types.container'}; const OPERATIONS = {update_targets: 'formTarget.target', start: 'action.start', stop: 'action.stop', restart: 'action.restart', remove: 'action.remove', create: 'saveCancel.create', create_from_catalog: 'newCatalog.saveNew', update_metadata: 'generic.edit', scale: 'serviceInfoPartial.scale', archive: 'archive.action', restore: 'archive.restore'}; const FIELDS = {targetServices: 'formTarget.target', hostname: 'formTarget.pointHostname', externalIpAddresses: 'formTarget.pointAddress', name: 'generic.name', description: 'generic.description', image: 'generic.image', scale: 'serviceInfoPartial.scale', diff --git a/bridge/README.md b/bridge/README.md index 04f121e381..78afd5cdce 100644 --- a/bridge/README.md +++ b/bridge/README.md @@ -5,7 +5,7 @@ command layer. It needs a compatible Engine with `api.ai.bridge.enabled=true`, a dedicated **AI collaboration bridge** Key and the parent repository's supported Node.js toolchain. It cannot approve its own change plans. -The [published 0.2.3 archive](https://github.com/PastureStack/web-console/releases/tag/1.6.201) +The [0.2.4 release archive](https://github.com/PastureStack/web-console/releases/tag/1.6.202) contains twelve typed tools for resource-family discovery, safe metadata reads and stack/service/container lifecycle plans. Unsupported-operation replies explain that Global services follow eligible @@ -18,11 +18,13 @@ domain resources. Pending adapters are reported by `list_capabilities`. External Services require an authorized Stack reference and exactly one target mode (hostname or IPs). DNS Aliases are created stopped and empty; target binding uses selected Service references in a separate approved plan. Typed audit queries -use the matching Engine 0.183.360 and broker owner, current Key scope and pinned -revision. Private audit payloads remain in the human UI. Web component and ZIP -qualification passed; matching Engine/Server and designated live workflows remain -pending. Publication does not update the existing Server `v1.6.559` / Web `1.6.200` -QA deployment or establish new-domain runtime acceptance. +use the matching Engine and broker owner, current Key scope and pinned revision. +Private audit payloads remain in the human UI. Certificate plans support only +name and description; PEM, private keys, chains, renewal and removal stay in the +normal human certificate UI. These metadata changes and the typed-domain audit +integration require Engine `0.183.361` or its matching Server release. A matching +version does not establish workflow acceptance; use the release's recorded +validation results and documented operation limits. The Catalog source adapter also needs a matching Engine/Catalog typed metadata endpoint. Use `find_resources` with `family: "catalog"` and @@ -46,14 +48,14 @@ native browser support. The operator must separately opt in to `api.ai.bridge.enabled` and, for upgrade plans, `api.ai.service.upgrade.enabled`; both Engine flags default to false. -Download the numeric release's `pasturestack-webmcp-bridge-0.2.3.zip` and its +Download the numeric release's `pasturestack-webmcp-bridge-0.2.4.zip` and its `.zip.sha256` checksum, then verify before extraction: ```sh -curl --fail --location --remote-name https://github.com/PastureStack/web-console/releases/download/1.6.201/pasturestack-webmcp-bridge-0.2.3.zip -curl --fail --location --remote-name https://github.com/PastureStack/web-console/releases/download/1.6.201/pasturestack-webmcp-bridge-0.2.3.zip.sha256 -sha256sum --check pasturestack-webmcp-bridge-0.2.3.zip.sha256 -unzip pasturestack-webmcp-bridge-0.2.3.zip +curl --fail --location --remote-name https://github.com/PastureStack/web-console/releases/download/1.6.202/pasturestack-webmcp-bridge-0.2.4.zip +curl --fail --location --remote-name https://github.com/PastureStack/web-console/releases/download/1.6.202/pasturestack-webmcp-bridge-0.2.4.zip.sha256 +sha256sum --check pasturestack-webmcp-bridge-0.2.4.zip.sha256 +unzip pasturestack-webmcp-bridge-0.2.4.zip ``` Extract the whole archive, keeping both `bridge/` and @@ -61,7 +63,7 @@ Extract the whole archive, keeping both `bridge/` and 24.22.1 available, install from the extracted directory: ```sh -cd pasturestack-webmcp-bridge-0.2.3/bridge +cd pasturestack-webmcp-bridge-0.2.4/bridge npm ci --ignore-scripts --no-audit --no-fund ``` @@ -70,7 +72,7 @@ records the included source hashes; the root also includes LICENSE and the bridge CycloneDX SBOM. To reproduce the source release from a checkout: ```sh -python scripts/package-webmcp-bridge.py --version 0.2.3 --sbom /path/to/webmcp-bridge.cdx.json --output /path/to/pasturestack-webmcp-bridge-0.2.3.zip +python scripts/package-webmcp-bridge.py --version 0.2.4 --sbom /path/to/webmcp-bridge.cdx.json --output /path/to/pasturestack-webmcp-bridge-0.2.4.zip ``` Store the following configuration outside the extracted source tree in a private diff --git a/bridge/package-lock.json b/bridge/package-lock.json index 06f8e47d2d..196dd0c412 100644 --- a/bridge/package-lock.json +++ b/bridge/package-lock.json @@ -1,12 +1,12 @@ { "name": "@pasturestack/webmcp-bridge", - "version": "0.2.3", + "version": "0.2.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@pasturestack/webmcp-bridge", - "version": "0.2.3", + "version": "0.2.4", "dependencies": { "@modelcontextprotocol/sdk": "1.32.2" }, diff --git a/bridge/package.json b/bridge/package.json index ef8019acf1..0440c56139 100644 --- a/bridge/package.json +++ b/bridge/package.json @@ -1,6 +1,6 @@ { "name": "@pasturestack/webmcp-bridge", - "version": "0.2.3", + "version": "0.2.4", "private": true, "type": "module", "description": "Isolated stdio adapter for the shared PastureStack command contract", diff --git a/bridge/server.mjs b/bridge/server.mjs index 7c64aaf836..b593915429 100644 --- a/bridge/server.mjs +++ b/bridge/server.mjs @@ -7,7 +7,7 @@ try { const path = process.env.PASTURESTACK_BRIDGE_CONFIG; if (!path) throw new SetupError('CONFIG_REQUIRED'); const bridge = await createBridge(await loadConfig(path)); - const server = new Server({name: 'pasturestack-webmcp-bridge', version: '0.2.3'}, { + const server = new Server({name: 'pasturestack-webmcp-bridge', version: '0.2.4'}, { capabilities: {tools: {}}, instructions: 'Use names and returned opaque references. Never guess IDs. A human must approve change plans in the console. Rejected calls must be repaired only as directed; never evade permissions. Accepted/running is not success. Read the existing operation until verifiedResult=true.', }); diff --git a/docs/baselines/npm-package-lock.sass-replacement.node24-ignore-scripts.json b/docs/baselines/npm-package-lock.sass-replacement.node24-ignore-scripts.json index cbf7ebd59f..7953b7ab84 100644 --- a/docs/baselines/npm-package-lock.sass-replacement.node24-ignore-scripts.json +++ b/docs/baselines/npm-package-lock.sass-replacement.node24-ignore-scripts.json @@ -1,12 +1,12 @@ { "name": "@pasturestack/web-console", - "version": "1.6.201", + "version": "1.6.202", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@pasturestack/web-console", - "version": "1.6.201", + "version": "1.6.202", "license": "Apache-2.0", "dependencies": { "sass": "1.103.1" diff --git a/docs/releases/web-console-1.6.202.md b/docs/releases/web-console-1.6.202.md new file mode 100644 index 0000000000..89c76492ea --- /dev/null +++ b/docs/releases/web-console-1.6.202.md @@ -0,0 +1,25 @@ +# Web Console 1.6.202 + +Source candidate; publication and designated workflow acceptance are pending. + +Certificate name and description can be changed through the existing resource +query, preview, normal human approval and execution flow. The optional stdio +bridge is 0.2.4. The public tool count remains twelve. + +The operation uses a normal metadata-only certificate PUT. Certificate material, +private keys, chains, renewal and deletion remain in the normal human UI. Key +scope, current account permissions, ownership, revision and compare-and-set +checks apply to both preview and execution. Plans cannot approve themselves. + +The matching Engine 0.183.361 must also support the certificate descriptor and +typed audit target. The same integration repairs External Service and DNS Alias +audit admission; disabling audit is not a workaround. + +Validation covers the exact metadata wire body, private-data rejection and safe +projection, confirmation boundary and the registered Engine schema. Existing +unaffected acceptance keeps its original evidence. A successful build or one +completed workflow is not complete frontend acceptance. + +Rollback requires the previous compatible Web/Engine/Server set. Let accepted +operations finish and cancel unused plans before changing versions; do not +mount an old JavaScript bundle over a newer image. diff --git a/package-lock.json b/package-lock.json index cbf7ebd59f..7953b7ab84 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@pasturestack/web-console", - "version": "1.6.201", + "version": "1.6.202", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@pasturestack/web-console", - "version": "1.6.201", + "version": "1.6.202", "license": "Apache-2.0", "dependencies": { "sass": "1.103.1" diff --git a/package.json b/package.json index c45240d9e7..50f903cd29 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@pasturestack/web-console", - "version": "1.6.201", + "version": "1.6.202", "private": true, "description": "PastureStack browser console for the compatible control platform.", "repository": { diff --git a/scripts/check-modernization-blockers b/scripts/check-modernization-blockers index 5430d79edc..57fa64aff6 100755 --- a/scripts/check-modernization-blockers +++ b/scripts/check-modernization-blockers @@ -41,8 +41,8 @@ with open('package.json', encoding='utf-8') as f: print(json.load(f).get('version', '')) PY ) -if [[ "$version" != "1.6.201" ]]; then - echo "UNEXPECTED_UI_ARTIFACT_VERSION version=$version expected=1.6.201" +if [[ "$version" != "1.6.202" ]]; then + echo "UNEXPECTED_UI_ARTIFACT_VERSION version=$version expected=1.6.202" failures=$((failures + 1)) fi diff --git a/scripts/ci b/scripts/ci index 7aefee14ba..7b7ff4e22c 100755 --- a/scripts/ci +++ b/scripts/ci @@ -14,6 +14,7 @@ node --test scripts/test-webmcp-path-boundaries.mjs node --test scripts/test-ai-catalog-reader.mjs scripts/test-webmcp-catalog.mjs scripts/test-webmcp-catalog-launch.mjs scripts/test-webmcp-archive.mjs scripts/test-webmcp-resources.mjs node --test scripts/test-webmcp-external-service.mjs node --test scripts/test-webmcp-alias-service.mjs +node --test scripts/test-webmcp-certificate-metadata.mjs node --test scripts/test-webmcp-audit.mjs scripts/test-webmcp-audit-extension.mjs node scripts/test-ai-collaboration-ui.js node scripts/test-ai-resource-review-labels.mjs diff --git a/scripts/package-webmcp-bridge.py b/scripts/package-webmcp-bridge.py index ca3c9eb12b..58e70e2945 100644 --- a/scripts/package-webmcp-bridge.py +++ b/scripts/package-webmcp-bridge.py @@ -11,7 +11,7 @@ import zipfile -VERSION = "0.2.3" +VERSION = "0.2.4" SOURCE_FILES = ( "LICENSE", "app/utils/ai-command-context.js", @@ -44,6 +44,7 @@ "scripts/test-webmcp-archive.mjs", "scripts/test-webmcp-external-service.mjs", "scripts/test-webmcp-alias-service.mjs", + "scripts/test-webmcp-certificate-metadata.mjs", "scripts/test-webmcp-audit.mjs", "scripts/test-webmcp-audit-extension.mjs", ) diff --git a/scripts/test-webmcp-certificate-metadata.mjs b/scripts/test-webmcp-certificate-metadata.mjs new file mode 100644 index 0000000000..dd36bfcd2b --- /dev/null +++ b/scripts/test-webmcp-certificate-metadata.mjs @@ -0,0 +1,86 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {COMMANDS, CommandContext, CommandEngine} from '../bridge/shared-modules.mjs'; +import {readFileSync} from 'node:fs'; +const {resourceReviewLabels} = await import('data:text/javascript;base64,' + readFileSync(new URL('../app/utils/ai-resource-review-labels.js', import.meta.url)).toString('base64')); + +const id = 'aichange-11111111-1111-1111-1111-111111111111'; +function fixture() { + const snapshot = {generation: 'g1', accountId: 'a1', projectId: 'p1', projectName: 'Environment', schemaProjectId: 'p1', schemaGeneration: 1, route: '/certificates'}; + let sequence = 0, plan, dispatches = 0; + const context = new CommandContext(() => snapshot, () => String(++sequence)); context.setMode('enabled'); + const requests = [], rows = ['first', 'selected'].map(suffix => ({id: `cert-${suffix}`, accountId: 'p1', name: 'same-name', description: suffix, + state: 'active', CN: `${suffix}.example.test`, cert: 'PRIVATE-CERT-CANARY', key: 'PRIVATE-KEY-CANARY', data: {key: 'PRIVATE-DATA-CANARY'}})); + const engine = new CommandEngine({context, transportKind: 'stdio', ensureSession: async () => {}, wait: async () => {}, + audit: async () => ({accepted: true, requestId: `audit${++sequence}`}), request: async request => { + requests.push(request); + if (request.url === 'projects/p1/aiResourceViews') return {body: {resourceType: 'certificate', + data: request.data.resourceId ? rows.filter(row => row.id === request.data.resourceId) : rows}}; + if (request.url === 'projects/p1/aiResourceChangePlans') { + const {resourceType, operation, resourceId, parameters} = request.data; + plan = {id, resourceType, operation, resourceId, targetPath: 'Environment / same-name', + changes: Object.entries(parameters).map(([field, after]) => ({field, before: field === 'name' ? 'same-name' : 'selected', after})), + confirmationState: 'pending', status: 'preview', verifiedResult: false, expiresAt: '2026-11-01T00:00:00Z'}; + return {body: {...plan}}; + } + if (request.url.endsWith('?action=confirm')) { plan.confirmationState = 'approved'; return {body: {...plan}}; } + if (request.url.endsWith('?action=apply')) { + if (plan.confirmationState !== 'approved') throw {status: 409, body: {code: 'ResourceChangeNeedsConfirmation'}}; + dispatches++; plan.status = 'accepted'; return {body: {...plan}}; + } + if (request.url.endsWith(id)) return {body: {...plan}}; + throw Error('Unexpected fixed route'); + }}); + return {engine, context, snapshot, requests, rows, plan: () => plan, dispatches: () => dispatches}; +} + +test('certificate: selected same-name opaque reference -> safe metadata -> human approved plan -> verified receipt', async () => { + const f = fixture(), found = await f.engine.execute('find_resources', {family: 'certificates', query: 'same-name'}); + assert.equal(found.items.length, 2); assert.notEqual(found.items[0].resourceRef, found.items[1].resourceRef); + const targetRef = found.items[1].resourceRef; + const detail = await f.engine.execute('get_resource', {resourceRef: targetRef}); + assert.equal(detail.fields.CN, 'selected.example.test'); assert.equal(JSON.stringify([found, detail]).includes('PRIVATE-'), false); + const preview = await f.engine.execute('preview_resource_change', {resourceType: 'certificate', operation: 'update_metadata', targetRef, + parameters: {name: 'renamed', description: ''}}); + assert.equal(preview.status, 'needs_user'); assert.equal(f.requests.at(-1).data.resourceId, 'cert-selected'); + assert.equal(JSON.stringify(preview).includes('cert-selected'), false); + assert.equal((await f.engine.execute('apply_resource_change', {planRef: preview.planRef})).code, 'NEEDS_USER'); + assert.equal(f.dispatches(), 0); await f.engine.confirmPlan(preview.planRef); + const accepted = await f.engine.execute('apply_resource_change', {planRef: preview.planRef}); + assert.equal(accepted.status, 'accepted'); assert.equal(f.dispatches(), 1); + f.plan().status = 'succeeded'; f.plan().verifiedResult = true; + const observed = await f.engine.execute('get_operation', {operationRef: accepted.operationRef}); + assert.equal(observed.status, 'succeeded'); assert.equal(observed.verifiedResult, true); + assert.ok(f.requests.every(request => request.method !== 'PUT' && !request.url.includes('/certificates'))); +}); + +test('certificate: material, raw IDs and unsupported intents never reach transport', async () => { + const f = fixture(), found = await f.engine.execute('find_resources', {family: 'certificates'}); + const input = {resourceType: 'certificate', operation: 'update_metadata', targetRef: found.items[0].resourceRef}; + const before = f.requests.length; + for (const field of ['cert', 'key', 'certChain', 'data', 'accountId', 'uuid', 'state']) { + assert.equal((await f.engine.execute('preview_resource_change', {...input, parameters: {[field]: 'PRIVATE-CANARY'}})).code, 'INVALID_INPUT'); + } + for (const operation of ['create', 'remove', 'start', 'stop', 'update_targets']) { + assert.equal((await f.engine.execute('preview_resource_change', {...input, operation, parameters: {name: 'x'}})).code, 'INVALID_INPUT'); + } + assert.equal((await f.engine.execute('preview_resource_change', {...input, targetRef: 'cert-first', parameters: {name: 'x'}})).code, 'STALE_REFERENCE'); + assert.equal(f.requests.length, before); +}); + +test('certificate: project changes invalidate issued references before preview', async () => { + const f = fixture(), found = await f.engine.execute('find_resources', {family: 'certificates'}); + const before = f.requests.length; f.snapshot.projectId = 'foreign'; f.snapshot.schemaProjectId = 'foreign'; + const rejected = await f.engine.execute('preview_resource_change', {resourceType: 'certificate', operation: 'update_metadata', + targetRef: found.items[0].resourceRef, parameters: {name: 'new'}}); + assert.ok(['CONTEXT_CHANGED', 'STALE_REFERENCE'].includes(rejected.code)); assert.equal(f.requests.length, before); +}); + +test('certificate: capabilities, closed input enum and human review expose only metadata changes', async () => { + const f = fixture(), capability = (await f.engine.execute('list_capabilities', {})).families.find(family => family.family === 'certificates').resources[0]; + assert.deepEqual(capability.operations, ['update_metadata']); + const tool = COMMANDS.find(tool => tool.name === 'preview_resource_change'); + assert.ok(tool.inputSchema.properties.resourceType.enum.includes('certificate')); + const labels = resourceReviewLabels({resourceType: 'certificate', operation: 'update_metadata', displayDiff: [{field: 'description', before: null, after: ''}]}, key => key); + assert.equal(labels.resourceTypeLabel, 'certificatesPage.index.header'); assert.equal(labels.operationLabel, 'generic.edit'); +}); diff --git a/scripts/test-webmcp-path-boundaries.mjs b/scripts/test-webmcp-path-boundaries.mjs index 23c74fea72..b5d7aee47e 100644 --- a/scripts/test-webmcp-path-boundaries.mjs +++ b/scripts/test-webmcp-path-boundaries.mjs @@ -69,7 +69,7 @@ async function fixture(dir){ const fields=Object.fromEntries(inputs[name].map(field=>[field,{create:true}])); if(name==='aiToolCallEvent'){fields.command.options=[...COMMANDS.map(x=>x.name),'unknown'];fields.reason.options=[...PUBLIC_ERROR_CODES,'NONE'];} if(name==='aiResourceChangePlan'){ - fields.resourceType.options=['stack','service','container','externalService','dnsService'];fields.operation.options=['start','stop','restart','remove','create','update_metadata','update_targets','scale','archive','restore'];fields.parameters.type='json'; + fields.resourceType.options=['stack','service','container','externalService','dnsService','certificate'];fields.operation.options=['start','stop','restart','remove','create','update_metadata','update_targets','scale','archive','restore'];fields.parameters.type='json'; } if(name==='aiCatalogMetadataView'){ for(const [field,limit] of Object.entries({query:256,marker:128,templateId:68,versionId:68}))Object.assign(fields[field],{type:'string',maxLength:limit});