Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions COMPATIBILITY.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,18 @@
# Compatibility Contract

## Web Console 1.6.204 / bridge 0.2.6 candidate

Audit admission preserves known 401/403 and verified Key-revision failures with
safe corrective guidance. It still requires a durable start ACK before any
business request. Infrastructure failures and invalid ACKs fail closed; an ACK
failure after a possible write remains UNKNOWN_RESULT without a second write.
Browser and stdio use the same command engine and public error contract.

Original full/enabled CI, CodeQL, artifact qualification, publication and matching
Server/native acceptance remain pending. This does not complete Audit acceptance
or the 243-group inventory. The current published component is 203 / 0.2.5 below.
See the [candidate note](docs/releases/web-console-1.6.204.md).

## Web Console 1.6.203 / bridge 0.2.5 published

Adds restricted-Key discovery against the existing Engine361 typed contract.
Expand Down
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,11 @@ history, authorship, licenses, and dependency notices.
is published with the original qualified CI artifacts. All 13 public release
assets passed actual anonymous GET, byte-count and SHA256 verification.

Web Console **1.6.204** / bridge **0.2.6** is a
[candidate](docs/releases/web-console-1.6.204.md) for clear audit-admission
authentication and Key-policy errors. Original CI/CodeQL, publication and
matching Server runtime acceptance remain pending.

Twelve shared typed tools provide resource discovery and human-reviewed changes
for supported workloads, External Services, DNS Aliases, audit metadata, and
Certificate names/descriptions. Certificate PEM material, keys, chains, renewal
Expand Down
3 changes: 3 additions & 0 deletions app/utils/ai-command-contract.js
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,9 @@ export function publicError(error) {
const entry = ERRORS[code] || ERRORS.UNKNOWN_RESULT;
const result = {status: entry[1], code, nextStep: entry[0], contractVersion: COMMAND_VERSION};
const serverCode = error && error.body && error.body.code;
if ( ['FORBIDDEN', 'CONTEXT_CHANGED'].includes(code) && serverCode === 'ApiKeyPolicyChanged' ) {
result.nextStep = 'The API Key policy changed. Stop this request and ask the user to review the Key and reconnect the bridge with its current policy. Do not retry automatically, broaden permissions, or try another ID or environment.';
}
if ( code === 'UNAVAILABLE' && serverCode === 'ResourceChangeFeatureDisabled' ) {
result.nextStep = 'Resource changes are disabled on this server. Ask an operator to enable the feature, or use the normal console.';
}
Expand Down
5 changes: 5 additions & 0 deletions app/utils/ai-command-engine.js
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,11 @@ export class CommandEngine {
let acknowledgement;
try { acknowledgement = await this.audit(event, captured); } catch (error) {
this.context.assertCurrent(captured);
// Admission still fails closed. Preserve verified authentication/policy
// failures so their repair does not suggest changing environments.
const status = Number(error && (error.status || error.statusCode || (error.response && error.response.status)));
if ( ['SESSION_REQUIRED', 'FORBIDDEN', 'CONTEXT_CHANGED', 'CONFLICT'].includes(publicError(error).code) &&
(error instanceof CommandError || [401, 403].includes(status)) ) { throw error; }
throw new CommandError('UNAVAILABLE');
}
this.context.assertCurrent(captured);
Expand Down
23 changes: 14 additions & 9 deletions bridge/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,12 @@ 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 [0.2.4 release archive](https://github.com/PastureStack/web-console/releases/tag/1.6.202)
Bridge **0.2.6** is a source candidate for clear audit-admission authentication
and Key-policy errors. It preserves start/result ACK, context and revision
protections. Required CI, publication and matching Server/native acceptance
remain pending; the download instructions below use the published 0.2.5.

The [0.2.5 release archive](https://github.com/PastureStack/web-console/releases/tag/1.6.203)
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
Expand Down Expand Up @@ -48,31 +53,31 @@ 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.4.zip` and its
Download the numeric release's `pasturestack-webmcp-bridge-0.2.5.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.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
curl --fail --location --remote-name https://github.com/PastureStack/web-console/releases/download/1.6.203/pasturestack-webmcp-bridge-0.2.5.zip
curl --fail --location --remote-name https://github.com/PastureStack/web-console/releases/download/1.6.203/pasturestack-webmcp-bridge-0.2.5.zip.sha256
sha256sum --check pasturestack-webmcp-bridge-0.2.5.zip.sha256
unzip pasturestack-webmcp-bridge-0.2.5.zip
```

Extract the whole archive, keeping both `bridge/` and
`app/utils/`; the bridge uses those exact shared command sources. With Node.js
24.22.1 available, install from the extracted directory:

```sh
cd pasturestack-webmcp-bridge-0.2.4/bridge
cd pasturestack-webmcp-bridge-0.2.5/bridge
npm ci --ignore-scripts --no-audit --no-fund
```

Its MCP entry point is the extracted `bridge/server.mjs`. `source-manifest.json`
records the included source hashes; the root also includes LICENSE and the
bridge CycloneDX SBOM. To reproduce the source release from a checkout:
bridge CycloneDX SBOM. To package this candidate from its source checkout (not a published release):

```sh
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
python scripts/package-webmcp-bridge.py --version 0.2.6 --sbom /path/to/webmcp-bridge.cdx.json --output /path/to/pasturestack-webmcp-bridge-0.2.6.zip
```

Store the following configuration outside the extracted source tree in a private
Expand Down
2 changes: 1 addition & 1 deletion bridge/core.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,7 @@ export function createTransport(config, fetcher = fetch) {
}
if (verifiedRevision !== null && revision !== verifiedRevision) {
await response.body?.cancel();
throw new CommandError('CONTEXT_CHANGED');
throw Object.assign(new CommandError('CONTEXT_CHANGED'), {body: {code: 'ApiKeyPolicyChanged'}});
}
verifiedRevision = revision;
}
Expand Down
4 changes: 2 additions & 2 deletions bridge/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion bridge/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@pasturestack/webmcp-bridge",
"version": "0.2.5",
"version": "0.2.6",
"private": true,
"type": "module",
"description": "Isolated stdio adapter for the shared PastureStack command contract",
Expand Down
2 changes: 1 addition & 1 deletion bridge/server.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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.5'}, {
const server = new Server({name: 'pasturestack-webmcp-bridge', version: '0.2.6'}, {
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.',
});
Expand Down
22 changes: 22 additions & 0 deletions bridge/test/core.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,28 @@ test('bootstrap pins a revision and every later request sends it before a mutati
assert.equal(writes,0);
});

test('audit admission reports real transport auth and revision failures without business dispatch', async () => {
for (const [status,code,expected] of [[401,'AuthenticationRequired','SESSION_REQUIRED'],
[403,'PermissionDenied','FORBIDDEN'],[403,'ApiKeyPolicyChanged','FORBIDDEN'],
[200,'ApiKeyPolicyChanged','CONTEXT_CHANGED']]) {
let admitted=false;const calls=[];
const bridge=await createBridge(config,{fetcher:async(url,options)=>{
calls.push({path:url.pathname,method:options.method,revision:options.headers['X-PastureStack-Key-Expected-Revision']});
if(!admitted)return response({data:[{id:'p1',name:'Default',state:'active'}]});
return status===200?response({accepted:true,requestId:'ack'},'2'):
new Response(JSON.stringify({code,message:secret}),{status});
}});
admitted=true;const initial=calls.length;
const result=await bridge.engine.execute('find_resources',{family:'audit',resourceType:'auditLog',auditFilters:{lastMinutes:10,limit:1}});
assert.equal(result.status,'rejected');assert.equal(result.code,expected);
assert.deepEqual(calls.slice(initial),[{path:'/v2-beta/projects/p1/aiToolCallEvents',method:'POST',revision:'1'}]);
assert.equal(JSON.stringify(result).includes(secret),false);
assert.doesNotMatch(result.nextStep,/select an environment|enable this integration/);
if(code==='ApiKeyPolicyChanged')assert.match(result.nextStep,/Key policy changed.*reconnect/);
bridge.close();
}
});

test('shared business engine lists scoped refs and reports authorization failure without cookie fallback', async () => {
const calls=[];
const bridge=await createBridge(config,{fetcher:async(url,opts)=>{
Expand Down
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
{
"name": "@pasturestack/web-console",
"version": "1.6.203",
"version": "1.6.204",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@pasturestack/web-console",
"version": "1.6.203",
"version": "1.6.204",
"license": "Apache-2.0",
"dependencies": {
"sass": "1.103.1"
Expand Down
41 changes: 41 additions & 0 deletions docs/releases/web-console-1.6.204.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
# Web Console 1.6.204 / bridge 0.2.6 candidate

This candidate is not published. Original full/enabled CI, required CodeQL,
artifact/SBOM qualification and matching Server/native acceptance remain pending.
Published Web Console 1.6.203 / bridge 0.2.5 remains the current release.

## Clear audit-admission errors

A known authentication or Key-policy denial from audit admission previously
became a generic unavailable-integration error. The shared command engine now
preserves verified 401/403 or internal authentication/context failures so the
reply gives the correct sign-in, permission or Key-reconnection guidance.
Verified stdio revision drift retains CONTEXT_CHANGED and uses a fixed safe
ApiKeyPolicyChanged marker; it never adopts the new revision automatically.

Admission remains fail-closed. No business request runs without a verified start
ACK. Generic 409, infrastructure/network failures and invalid ACKs remain
unavailable. Context replacement takes precedence over a stale denial. A result
ACK failure after a possible write stays UNKNOWN_RESULT with its authorized
recovery reference; it does not claim rejection or submit another change.
Arbitrary error messages, detail, payloads, credentials and headers are not
projected into tool results. Browser and stdio use the same command sources.

Five new regression cases and twelve directly affected source-focused cases
passed in the isolated correction, with the original causal failures retained.
Those are source results, not new original-CI or native acceptance. The existing
CI entry point now includes the audit-focused command cases and actual bridge
core transport tests without removing any previous gate. Fresh CI totals and
artifact digests must come from that producer's actual reports.

The package/lock updates are version metadata only; dependencies and security
decisions are unchanged. Existing restricted-Key discovery, helper contracts,
pending-review layout, typed tools, Full/legacy behavior, role/Key ceilings,
current-context checks and human confirmation are retained. WebMCP remains
independently build-gated and starts off on every page.

This client correction does not complete Audit acceptance, native plan
read/apply or Member cleanup. Matching Engine/Server fixes, role/Key/cross-ID
checks and designated workflows still need their own fresh evidence. The
243-group inventory remains in progress. Consume a qualified published
component through its matching Server release; do not replace runtime files.
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@pasturestack/web-console",
"version": "1.6.203",
"version": "1.6.204",
"private": true,
"description": "PastureStack browser console for the compatible control platform.",
"repository": {
Expand Down
4 changes: 2 additions & 2 deletions scripts/check-modernization-blockers
Original file line number Diff line number Diff line change
Expand Up @@ -41,8 +41,8 @@ with open('package.json', encoding='utf-8') as f:
print(json.load(f).get('version', ''))
PY
)
if [[ "$version" != "1.6.203" ]]; then
echo "UNEXPECTED_UI_ARTIFACT_VERSION version=$version expected=1.6.203"
if [[ "$version" != "1.6.204" ]]; then
echo "UNEXPECTED_UI_ARTIFACT_VERSION version=$version expected=1.6.204"
failures=$((failures + 1))
fi

Expand Down
2 changes: 2 additions & 0 deletions scripts/ci
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ 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-webmcp-commands.js --filter=audit
node --test bridge/test/core.test.mjs
node scripts/test-ai-collaboration-ui.js
node scripts/test-ai-resource-review-labels.mjs
node --test scripts/test-ai-resource-confirm-errors.mjs
Expand Down
2 changes: 1 addition & 1 deletion scripts/package-webmcp-bridge.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
import zipfile


VERSION = "0.2.5"
VERSION = "0.2.6"
SOURCE_FILES = (
"LICENSE",
"app/utils/ai-command-context.js",
Expand Down
69 changes: 68 additions & 1 deletion scripts/test-webmcp-commands.js
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ function deferred() {
async function main() {
const {CommandContext} = await import(moduleUrl('ai-command-context'));
const {CommandEngine, waitForOperationPoll} = await import(moduleUrl('ai-command-engine'));
const {COMMANDS, publicError} = await import(moduleUrl('ai-command-contract'));
const {COMMANDS, CommandError, publicError} = await import(moduleUrl('ai-command-contract'));
const legacyNames = ['find_stacks', 'find_services', 'get_service', 'update_service_description', 'preview_service_upgrade', 'apply_service_upgrade', 'get_operation'];
const {registerCommandTools} = await import(moduleUrl('webmcp-tools'));
let count = 0;
Expand Down Expand Up @@ -126,6 +126,73 @@ async function main() {
assert.equal(f.requests.length, 0); assert.equal(events.length, 1);
});

await test('audit admission preserves authentication denials and safe Key policy repair', async () => {
for ( const [error, code] of [
[{status: 401, body: {message: 'SECRET_SENTINEL'}}, 'SESSION_REQUIRED'],
[{status: 403, body: {code: 'PermissionDenied', message: 'SECRET_SENTINEL'}}, 'FORBIDDEN'],
[{status: 403, body: {code: 'ApiKeyPolicyChanged', message: 'SECRET_SENTINEL'}}, 'FORBIDDEN'],
[Object.assign(new CommandError('CONTEXT_CHANGED'), {body: {code: 'ApiKeyPolicyChanged'}}), 'CONTEXT_CHANGED'],
[new CommandError('FORBIDDEN'), 'FORBIDDEN'],
] ) {
const f = fixture(); let attempts = 0;
f.engine.audit = async () => { attempts++; throw error; };
const result = await f.engine.execute('find_stacks', {});
assert.equal(result.status, 'rejected'); assert.equal(result.code, code);
assert.equal(attempts, 1); assert.equal(f.requests.length, 0); assert.equal(f.ensureCalls(), 0);
assert.equal(JSON.stringify(result).includes('SECRET_SENTINEL'), false);
assert.doesNotMatch(result.nextStep, /select an environment|enable this integration/);
if ( error.body?.code === 'ApiKeyPolicyChanged' ) {
assert.match(result.nextStep, /Key policy changed/); assert.match(result.nextStep, /reconnect/);
assert.match(result.nextStep, /Do not retry automatically/);
}
}
});

await test('audit admission keeps infrastructure and unverified acknowledgements unavailable', async () => {
for ( const error of [{status: 500, body: {code: 'ApiKeyPolicyChanged'}}, {status: 503},
{status: 409, body: {code: 'UnrecognizedAuditFailure'}}, new Error('SECRET_SENTINEL')] ) {
const f = fixture(); f.engine.audit = async () => { throw error; };
assert.equal((await f.engine.execute('find_stacks', {})).code, 'UNAVAILABLE');
assert.equal(f.requests.length, 0); assert.equal(f.ensureCalls(), 0);
}
for ( const acknowledgement of [null, {}, {accepted: false, requestId: 'not-durable'},
{accepted: true}, {accepted: true, requestId: ''}] ) {
const f = fixture(); f.engine.audit = async () => acknowledgement;
assert.equal((await f.engine.execute('find_stacks', {})).code, 'UNAVAILABLE');
assert.equal(f.requests.length, 0); assert.equal(f.ensureCalls(), 0);
}
});

await test('audit admission auth errors on result ACK never reject an already submitted apply', async () => {
for ( const status of [401, 403] ) {
const f = fixture(), plan = await preview(f); await f.engine.confirmPlan(plan.planRef);
const audit = f.engine.audit;
f.engine.audit = async (event, captured) => {
if ( event.command === 'apply_service_upgrade' && event.phase === 'result' ) {
throw {status, body: {code: 'ApiKeyPolicyChanged', message: 'SECRET_SENTINEL'}};
}
return audit(event, captured);
};
const result = await f.engine.execute('apply_service_upgrade', {planRef: plan.planRef});
assert.equal(result.status, 'unknown'); assert.equal(result.code, 'UNKNOWN_RESULT');
assert.ok(result.operationRef); assert.match(result.nextStep, /Do not submit another change/);
assert.equal(f.requests.filter((item) => item.url.endsWith('?action=apply')).length, 1);
assert.equal(JSON.stringify(result).includes('SECRET_SENTINEL'), false);
}
});

await test('audit admission context replacement takes precedence over an old session denial', async () => {
const f = fixture(), barrier = deferred(); let attempts = 0;
f.engine.audit = async () => { attempts++; return barrier.promise; };
const pending = f.engine.execute('find_stacks', {});
await Promise.resolve(); f.snapshot.generation = 'replacement-session';
barrier.reject({status: 403, body: {code: 'ApiKeyPolicyChanged'}});
const result = await pending;
assert.equal(result.code, 'CONTEXT_CHANGED'); assert.equal(result.items, undefined);
assert.doesNotMatch(result.nextStep, /Key policy changed/);
assert.equal(attempts, 1); assert.equal(f.requests.length, 0); assert.equal(f.ensureCalls(), 0);
});

await test('result audit failure after apply preserves recovery reference without a second mutation', async () => {
const f = fixture(), plan = await preview(f);
await f.engine.confirmPlan(plan.planRef);
Expand Down
Loading