@aiwg/cockpit 2026.9.6 → 2026.9.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bridge/src/server.mjs +71 -17
- package/bridge/src/smoke.mjs +319 -232
- package/package.json +1 -1
- package/web/dist/assets/index-i6bfoB4K.js +312 -0
- package/web/dist/index.html +1 -1
- package/web/dist/assets/index-DWYeO6Yy.js +0 -312
package/bridge/src/smoke.mjs
CHANGED
|
@@ -1,260 +1,347 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
1
|
+
// Mock executor + real Bridge HTTP + strict CLI contract fixture + canonical corpus.
|
|
2
|
+
// Actual installed CLI discovery/show integration is a separate, unresolved lane.
|
|
3
3
|
import assert from 'node:assert/strict';
|
|
4
4
|
import { fileURLToPath } from 'node:url';
|
|
5
|
+
import { mkdtemp, mkdir, readFile, writeFile, rm } from 'node:fs/promises';
|
|
6
|
+
import { tmpdir } from 'node:os';
|
|
7
|
+
import { join } from 'node:path';
|
|
8
|
+
import { randomUUID } from 'node:crypto';
|
|
5
9
|
import { createExecutor, DEFAULT_INSTANCE } from '../../mock-executor/src/server.mjs';
|
|
6
|
-
import {
|
|
7
|
-
createBridge,
|
|
8
|
-
localLibvirtFallbackAllowed,
|
|
9
|
-
normalizeSessionRows,
|
|
10
|
-
selectCockpitA2AInterface,
|
|
11
|
-
} from './server.mjs';
|
|
10
|
+
import { createBridge, isDirectExecution, localLibvirtFallbackAllowed, normalizeSessionRows, selectCockpitA2AInterface } from './server.mjs';
|
|
12
11
|
|
|
13
|
-
const
|
|
14
|
-
|
|
15
|
-
const
|
|
16
|
-
const executorUrl = `http://127.0.0.1:${executorPort}`;
|
|
12
|
+
const CORPUS = fileURLToPath(new URL('../../../../agentic/code', import.meta.url));
|
|
13
|
+
const SKILL_PATH = fileURLToPath(new URL('../../../../agentic/code/frameworks/sdlc-complete/skills/flow-deploy-to-production/SKILL.md', import.meta.url));
|
|
14
|
+
const CONTRIBUTIONS = fileURLToPath(new URL('../../contrib', import.meta.url));
|
|
17
15
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
16
|
+
/** Contract double: never dispatch an unexpected command to the real CLI. */
|
|
17
|
+
export function createSmokeCliFixture(calls = []) {
|
|
18
|
+
return async (args) => {
|
|
19
|
+
calls.push([...args]);
|
|
20
|
+
if (JSON.stringify(args) === JSON.stringify(['discover', 'deploy production', '--json', '--limit', '4'])) {
|
|
21
|
+
return JSON.stringify({ results: [{ id: 'smoke:flow-deploy-to-production', name: 'flow-deploy-to-production', type: 'skill', path: SKILL_PATH }] });
|
|
22
|
+
}
|
|
23
|
+
if (JSON.stringify(args) === JSON.stringify(['show', 'skill', 'flow-deploy-to-production'])) return readFile(SKILL_PATH, 'utf8');
|
|
24
|
+
if (JSON.stringify(args) === JSON.stringify(['show', 'agent', '__definitely_not_a_real_artifact__'])) throw new Error('artifact not found: smoke missing-artifact fixture');
|
|
25
|
+
throw new Error(`Unexpected smoke CLI command: ${JSON.stringify(args)}`);
|
|
26
|
+
};
|
|
27
|
+
}
|
|
23
28
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
+
async function listen(server) {
|
|
30
|
+
await new Promise((resolve, reject) => {
|
|
31
|
+
server.once('error', reject);
|
|
32
|
+
server.listen(0, '127.0.0.1', () => { server.off('error', reject); resolve(); });
|
|
33
|
+
});
|
|
34
|
+
}
|
|
29
35
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
assert.equal(inv.instances.find((i) => i.runtime === 'host')?.runtime_posture.isolation, 'least', 'host is least-isolated');
|
|
38
|
-
assert.equal(inv.instances.find((i) => i.runtime === 'wasm-edge')?.runtime_posture.isolation, 'opaque', 'future runtime is opaque');
|
|
39
|
-
assert.equal(inv.instances.find((i) => i.transport?.mode === 'shared-secret')?.transport.trust, 'compatibility', 'legacy secret transport is compatibility posture');
|
|
40
|
-
const i0 = inv.instances[0];
|
|
41
|
-
for (const k of ['id', 'runtime', 'loadout', 'state', 'tenant', 'card_url', 'runtime_posture', 'host_daemon', 'transport', 'launch_context', 'session_backends']) assert.ok(k in i0, `field ${k}`);
|
|
42
|
-
assert.equal(i0.storage?.persistent, true, 'storage persistence surfaced');
|
|
43
|
-
assert.equal(i0.storage?.delete_on_destroy, true, 'storage delete-on-destroy surfaced');
|
|
44
|
-
assert.ok(['vm', 'container', 'host', 'wasm-edge'].includes(i0.runtime), 'runtime kind');
|
|
45
|
-
assert.equal(i0.a2a_protocol?.selected_version, '0.3', 'inventory exposes the selected A2A protocol');
|
|
46
|
-
assert.equal(selectCockpitA2AInterface({
|
|
47
|
-
name: 'dual',
|
|
48
|
-
version: '1',
|
|
49
|
-
protocolVersion: '0.3.0',
|
|
50
|
-
url: 'https://legacy.test/agent',
|
|
51
|
-
supportedInterfaces: [
|
|
52
|
-
{ url: 'https://v1.test/agent', protocolBinding: 'HTTP+JSON', protocolVersion: '1.0' },
|
|
53
|
-
{ url: 'https://legacy.test/agent', transport: 'REST', protocolVersion: '0.3' },
|
|
54
|
-
],
|
|
55
|
-
}, 'auto').protocol_version, '1.0', 'Cockpit auto selects the preferred 1.0 interface');
|
|
36
|
+
async function close(server) {
|
|
37
|
+
if (!server?.listening) return;
|
|
38
|
+
await new Promise((resolve, reject) => {
|
|
39
|
+
server.close((error) => error ? reject(error) : resolve());
|
|
40
|
+
server.closeIdleConnections?.();
|
|
41
|
+
});
|
|
42
|
+
}
|
|
56
43
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
44
|
+
/** Own only the exact mkdtemp directory created here, including partial setup. */
|
|
45
|
+
export async function withSmokeFixture(callback, { temporaryParent = tmpdir() } = {}) {
|
|
46
|
+
const fixtureRoot = await mkdtemp(join(temporaryParent, 'aiwg-cockpit-smoke-'));
|
|
47
|
+
let mock, bridge, result, failure;
|
|
48
|
+
try {
|
|
49
|
+
const libraryDir = join(fixtureRoot, 'library'), auditDir = join(fixtureRoot, 'audit');
|
|
50
|
+
await mkdir(libraryDir);
|
|
51
|
+
await mkdir(auditDir);
|
|
52
|
+
// fs.cp checks destination ancestors up to the source parent. Keep clone
|
|
53
|
+
// source and library under the same owned root without granting /tmp reads.
|
|
54
|
+
// This is a canonical-content fixture, not installed-corpus clone coverage.
|
|
55
|
+
const cloneSourceDir = join(fixtureRoot, 'clone-source');
|
|
56
|
+
const skillPath = join(cloneSourceDir, 'SKILL.md');
|
|
57
|
+
await mkdir(cloneSourceDir);
|
|
58
|
+
await writeFile(skillPath, await readFile(SKILL_PATH), { flag: 'wx', mode: 0o600 });
|
|
59
|
+
const cliCalls = [];
|
|
60
|
+
mock = createExecutor({ protocolMode: '0.3' });
|
|
61
|
+
await listen(mock);
|
|
62
|
+
const executorPort = mock.address().port;
|
|
63
|
+
const executorUrl = `http://127.0.0.1:${executorPort}`;
|
|
64
|
+
bridge = createBridge({
|
|
65
|
+
executorUrl, allowMockExecutor: true, executorTokenFile: '', mcpTokenFile: '',
|
|
66
|
+
requireSandboxMtls: false, a2aProtocolPolicy: '0.3', allowA2AProtocolFallback: false,
|
|
67
|
+
localDockerFallback: false, localLibvirtFallback: false,
|
|
68
|
+
libraryDir, auditDir, corpusRoots: [CORPUS], contributionDirs: [CONTRIBUTIONS],
|
|
69
|
+
aiwgCommand: createSmokeCliFixture(cliCalls),
|
|
70
|
+
});
|
|
71
|
+
await listen(bridge);
|
|
72
|
+
const base = `http://127.0.0.1:${bridge.address().port}`;
|
|
73
|
+
const f = (p, o = {}) => fetch(base + p, { ...o, headers: { ...(o.headers || {}), authorization: 'Bearer ' + bridge.cockpitToken } });
|
|
74
|
+
result = await callback({ fixtureRoot, libraryDir, auditDir, mock, bridge, base, f, executorPort, executorUrl, cliCalls, skillPath });
|
|
75
|
+
} catch (error) { failure = error; }
|
|
76
|
+
const cleanupErrors = [];
|
|
77
|
+
for (const server of [bridge, mock]) {
|
|
78
|
+
try { await close(server); } catch (error) { cleanupErrors.push(error); }
|
|
79
|
+
}
|
|
80
|
+
try { await rm(fixtureRoot, { recursive: true, force: true }); } catch (error) { cleanupErrors.push(error); }
|
|
81
|
+
if (failure && cleanupErrors.length) throw new AggregateError([failure, ...cleanupErrors], 'Smoke failed and cleanup also failed', { cause: failure });
|
|
82
|
+
if (failure) throw failure;
|
|
83
|
+
if (cleanupErrors.length) throw new AggregateError(cleanupErrors, 'Smoke cleanup failed');
|
|
84
|
+
return result;
|
|
85
|
+
}
|
|
66
86
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
assert.
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
87
|
+
/** A 201 establishes ownership of this requested safe directory name, never a 400. */
|
|
88
|
+
export async function exerciseLibraryRoundTrip({ f, sourcePath, name, afterCreate = async () => {} }) {
|
|
89
|
+
assert.match(name, /^[a-zA-Z0-9][a-zA-Z0-9_-]+$/, 'smoke target name is safe and unchanged by library normalization');
|
|
90
|
+
let created = false, failure;
|
|
91
|
+
try {
|
|
92
|
+
const response = await f(`/api/library/clone?type=skill&name=${encodeURIComponent(name)}&path=${encodeURIComponent(sourcePath)}`, { method: 'POST' });
|
|
93
|
+
assert.equal(response.status, 201, 'clone returns 201 for a newly owned asset');
|
|
94
|
+
// Mark ownership before parsing: malformed response JSON must still trigger cleanup.
|
|
95
|
+
created = true;
|
|
96
|
+
const cloned = await response.json();
|
|
97
|
+
assert.equal(cloned.name, name, 'clone response names the newly owned asset');
|
|
98
|
+
assert.equal(cloned.type, 'skill', 'clone response preserves skill type');
|
|
99
|
+
assert.equal(cloned.kind, 'dir', 'smoke clones a skill directory');
|
|
100
|
+
const listed = await (await f('/api/library')).json();
|
|
101
|
+
assert.ok(listed.library.some((asset) => asset.name === name), 'cloned asset appears in the user library');
|
|
102
|
+
await afterCreate();
|
|
103
|
+
} catch (error) { failure = error; }
|
|
104
|
+
let cleanupError;
|
|
105
|
+
if (created) {
|
|
106
|
+
try {
|
|
107
|
+
assert.equal((await f(`/api/library/${encodeURIComponent(name)}`, { method: 'DELETE' })).status, 200, 'library delete 200');
|
|
108
|
+
const listed = await (await f('/api/library')).json();
|
|
109
|
+
assert.ok(!listed.library.some((asset) => asset.name === name), 'deleted asset removed from library');
|
|
110
|
+
} catch (error) { cleanupError = error; }
|
|
111
|
+
}
|
|
112
|
+
if (failure && cleanupError) throw new AggregateError([failure, cleanupError], 'Library smoke failed and cleanup also failed', { cause: failure });
|
|
113
|
+
if (failure) throw failure;
|
|
114
|
+
if (cleanupError) throw cleanupError;
|
|
115
|
+
}
|
|
75
116
|
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
assert.match(demo.attach_url, /^ws:\/\/.*\/api\/pty\/agents\/.*\/sessions\/demo-shell\/attach\/[A-Za-z0-9_-]+$/, 'ws attach_url shape');
|
|
83
|
-
assert.ok(demo.liveness.replay_newest_seq >= 3, 'demo session has a seeded transcript');
|
|
84
|
-
assert.equal(demo.session_class, 'direct', 'demo session class');
|
|
85
|
-
assert.equal(demo.session_backend, 'native', 'demo session backend');
|
|
86
|
-
assert.equal(demo.role_policy, 'observe-default', 'session role policy');
|
|
117
|
+
export async function runBridgeSmoke(options = {}) {
|
|
118
|
+
return withSmokeFixture(async ({ mock, bridge, base, f, executorPort, executorUrl, skillPath }) => {
|
|
119
|
+
// auth gate: /api/ without the token is 401; /healthz is open
|
|
120
|
+
assert.equal((await fetch(`${base}/api/inventory`)).status, 401, 'gate: no token -> 401');
|
|
121
|
+
assert.equal((await fetch(`${base}/api/inventory?token=${encodeURIComponent(bridge.cockpitToken)}`)).status, 401, 'gate: URL token is never accepted');
|
|
122
|
+
assert.equal((await fetch(`${base}/healthz`)).status, 200, 'healthz open (no token)');
|
|
87
123
|
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
124
|
+
// data path: Bridge reads the executor admin inventory
|
|
125
|
+
const r = await f('/api/inventory');
|
|
126
|
+
assert.equal(r.status, 200, 'inventory 200 (authed)');
|
|
127
|
+
const inv = await r.json();
|
|
128
|
+
assert.equal(inv.count, 4, 'four demo instances');
|
|
129
|
+
const ids = inv.instances.map((i) => i.id);
|
|
130
|
+
assert.ok(ids.includes('550e8400-e29b-41d4-a716-446655440000'), 'default instance present');
|
|
131
|
+
assert.equal(inv.instances.find((i) => i.runtime === 'host')?.runtime_posture.isolation, 'least', 'host is least-isolated');
|
|
132
|
+
assert.equal(inv.instances.find((i) => i.runtime === 'wasm-edge')?.runtime_posture.isolation, 'opaque', 'future runtime is opaque');
|
|
133
|
+
assert.equal(inv.instances.find((i) => i.transport?.mode === 'shared-secret')?.transport.trust, 'compatibility', 'legacy secret transport is compatibility posture');
|
|
134
|
+
const i0 = inv.instances[0];
|
|
135
|
+
for (const k of ['id', 'runtime', 'loadout', 'state', 'tenant', 'card_url', 'runtime_posture', 'host_daemon', 'transport', 'launch_context', 'session_backends']) assert.ok(k in i0, `field ${k}`);
|
|
136
|
+
assert.equal(i0.storage?.persistent, true, 'storage persistence surfaced');
|
|
137
|
+
assert.equal(i0.storage?.delete_on_destroy, true, 'storage delete-on-destroy surfaced');
|
|
138
|
+
assert.ok(['vm', 'container', 'host', 'wasm-edge'].includes(i0.runtime), 'runtime kind');
|
|
139
|
+
assert.equal(i0.a2a_protocol?.selected_version, '0.3', 'inventory exposes the selected A2A protocol');
|
|
140
|
+
assert.equal(selectCockpitA2AInterface({
|
|
141
|
+
name: 'dual',
|
|
142
|
+
version: '1',
|
|
143
|
+
protocolVersion: '0.3.0',
|
|
144
|
+
url: 'https://legacy.test/agent',
|
|
145
|
+
supportedInterfaces: [
|
|
146
|
+
{ url: 'https://v1.test/agent', protocolBinding: 'HTTP+JSON', protocolVersion: '1.0' },
|
|
147
|
+
{ url: 'https://legacy.test/agent', transport: 'REST', protocolVersion: '0.3' },
|
|
148
|
+
],
|
|
149
|
+
}, 'auto').protocol_version, '1.0', 'Cockpit auto selects the preferred 1.0 interface');
|
|
113
150
|
|
|
114
|
-
|
|
115
|
-
|
|
151
|
+
// A transient executor outage must not poison Bridge state or require a
|
|
152
|
+
// Bridge restart. Every poll is a fresh upstream request, so the same Bridge
|
|
153
|
+
// reports the gap and resumes inventory as soon as the executor returns.
|
|
154
|
+
await new Promise((resolve) => mock.close(resolve));
|
|
155
|
+
assert.equal((await f('/api/inventory')).status, 502, 'transient executor drop -> 502');
|
|
156
|
+
await new Promise((resolve) => mock.listen(executorPort, '127.0.0.1', resolve));
|
|
157
|
+
const recoveredInventory = await f('/api/inventory');
|
|
158
|
+
assert.equal(recoveredInventory.status, 200, 'same Bridge resumes after executor returns');
|
|
159
|
+
assert.equal((await recoveredInventory.json()).count, 4, 'recovered inventory is complete');
|
|
116
160
|
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
161
|
+
// running board: seeded working tasks on the running instances
|
|
162
|
+
const rr = await f("/api/running");
|
|
163
|
+
assert.equal(rr.status, 200, 'running 200');
|
|
164
|
+
const run = await rr.json();
|
|
165
|
+
assert.ok(run.count >= 2, 'at least two running tasks seeded');
|
|
166
|
+
for (const k of ['instance_id', 'task_id', 'state', 'tenant']) assert.ok(k in run.running[0], `running field ${k}`);
|
|
167
|
+
for (const k of ['runtime_posture', 'transport']) assert.ok(k in run.running[0], `running posture field ${k}`);
|
|
168
|
+
assert.equal(run.running[0].state, 'working', 'running task is working');
|
|
125
169
|
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
assert.equal(localLibvirtFallbackAllowed('linux', undefined), true, 'Linux bridge hosts retain local virsh fallback');
|
|
170
|
+
// sessions: the demo pty session is listed with a Bridge-owned ws attach_url
|
|
171
|
+
const sr = await f("/api/sessions?instance=550e8400-e29b-41d4-a716-446655440000");
|
|
172
|
+
assert.equal(sr.status, 200, 'sessions 200');
|
|
173
|
+
const sess = await sr.json();
|
|
174
|
+
const demo = sess.sessions.find((s) => s.id === 'demo-shell');
|
|
175
|
+
assert.ok(demo, 'demo-shell session present');
|
|
176
|
+
assert.match(demo.attach_url, /^ws:\/\/.*\/api\/pty\/agents\/.*\/sessions\/demo-shell\/attach\/[A-Za-z0-9_-]+$/, 'ws attach_url shape');
|
|
177
|
+
assert.ok(demo.liveness.replay_newest_seq >= 3, 'demo session has a seeded transcript');
|
|
178
|
+
assert.equal(demo.session_class, 'direct', 'demo session class');
|
|
179
|
+
assert.equal(demo.session_backend, 'native', 'demo session backend');
|
|
180
|
+
assert.equal(demo.role_policy, 'observe-default', 'session role policy');
|
|
138
181
|
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
182
|
+
const qemuDedup = normalizeSessionRows({
|
|
183
|
+
executorUrl,
|
|
184
|
+
instanceId: 'vm-1',
|
|
185
|
+
sessionAgentId: 'vm-agent-name',
|
|
186
|
+
sessions: [
|
|
187
|
+
{
|
|
188
|
+
id: 'sess-formal',
|
|
189
|
+
session_id: 'sess-formal',
|
|
190
|
+
command_id: 'cmd-real',
|
|
191
|
+
session_name: 'terminal-qemu',
|
|
192
|
+
command: '/bin/bash',
|
|
193
|
+
has_screen: true,
|
|
194
|
+
},
|
|
195
|
+
{
|
|
196
|
+
id: 'sess-formal',
|
|
197
|
+
session_id: 'sess-formal',
|
|
198
|
+
command_id: 'sess-formal',
|
|
199
|
+
session_name: 'sess-formal',
|
|
200
|
+
command: '/bin/bash -l',
|
|
201
|
+
has_screen: false,
|
|
202
|
+
},
|
|
203
|
+
],
|
|
204
|
+
});
|
|
205
|
+
assert.equal(qemuDedup.sessions.length, 1, 'QEMU formal session + fallback row dedupe to one session');
|
|
206
|
+
assert.equal(qemuDedup.sessions[0].session_name, 'terminal-qemu', 'dedupe keeps the named screen-backed session');
|
|
150
207
|
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
assert.equal(mcp.endpoint?.path, '/mcp', 'MCP endpoint path surfaced');
|
|
154
|
-
assert.equal(mcp.endpoint?.mcp_session_id, false, 'MCP discovery is stateless/no session id');
|
|
155
|
-
assert.ok(mcp.tools.some((tool) => tool.name === 'list_sandboxes'), 'MCP tools surfaced');
|
|
156
|
-
assert.ok(mcp.resource_templates.some((template) => template.uriTemplate === 'sandbox://sessions/{session_id}/screen'), 'MCP resource templates surfaced');
|
|
157
|
-
assert.equal((await f('/api/mcp', { method: 'POST', body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/list' }) })).status, 503, 'MCP proxy fail-closed without token file');
|
|
158
|
-
const gatedReconnect = await f(`/api/instances/${DEFAULT_INSTANCE}/reconnect`, { method: 'POST' });
|
|
159
|
-
assert.equal(gatedReconnect.status, 409, 'Docker reconnect fallback is gated unless local dev flag is set');
|
|
160
|
-
assert.equal((await gatedReconnect.json()).error, 'local_docker_fallback_disabled', 'Docker reconnect fallback error is explicit');
|
|
208
|
+
// missing instance param is a 400
|
|
209
|
+
assert.equal((await f("/api/sessions")).status, 400, 'sessions requires instance');
|
|
161
210
|
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
assert.equal((await f("/api/capabilities")).status, 400, 'capabilities requires q');
|
|
171
|
-
// show by discovered PATH — deterministic, sidesteps ambiguous same-named artifacts (#1643)
|
|
172
|
-
if (hit.path) {
|
|
173
|
-
const shownByPath = await (await f(`/api/show?path=${encodeURIComponent(hit.path)}`)).json();
|
|
174
|
-
assert.match(shownByPath.body, /name:\s*flow-deploy-to-production/, 'show-by-path returns the body');
|
|
175
|
-
assert.equal(shownByPath.path, hit.path, 'show-by-path echoes the resolved path');
|
|
176
|
-
} else {
|
|
177
|
-
assert.ok(hit.id, 'pathless discover result carries a stable id');
|
|
178
|
-
}
|
|
179
|
-
// a missing artifact is a 4xx, never a 502 (ambiguous/not-found map to operator-correctable input)
|
|
180
|
-
assert.equal((await f('/api/show?type=agent&name=__definitely_not_a_real_artifact__')).status, 404, 'unknown artifact -> 404 not 502');
|
|
181
|
-
// a path outside the AIWG corpus is refused (no traversal)
|
|
182
|
-
assert.equal((await f(`/api/show?path=${encodeURIComponent('/etc/passwd')}`)).status, 400, 'path outside corpus -> 400');
|
|
211
|
+
// loadout catalog passthrough — the start-session picker offers the full set (#1641)
|
|
212
|
+
const lo = await (await f('/api/loadouts')).json();
|
|
213
|
+
assert.ok(Array.isArray(lo.loadouts) && lo.loadouts.length >= 3, 'loadout catalog returned');
|
|
214
|
+
assert.ok(lo.loadouts.every((l) => typeof l.id === 'string' && typeof l.label === 'string'), 'loadouts carry id+label');
|
|
215
|
+
assert.ok(lo.loadouts.some((l) => l.id === 'security-audit'), 'catalog includes a non-default loadout');
|
|
216
|
+
const gpuLoadout = lo.loadouts.find((l) => l.id === 'gpu-vfio');
|
|
217
|
+
assert.ok(gpuLoadout?.runtime_options?.required_capabilities?.includes('device.vfio'), 'loadout runtime_options preserve VFIO requirement');
|
|
218
|
+
assert.ok(gpuLoadout?.compatibility?.[0]?.excluded_capabilities?.includes('instance.restore'), 'loadout compatibility preserves fast-start exclusion');
|
|
183
219
|
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
220
|
+
const caps = await (await f('/api/executor/capabilities')).json();
|
|
221
|
+
assert.ok(caps.runtime_providers?.providers?.some((p) => p.provider === 'cloud-hypervisor'), 'runtime providers discovered');
|
|
222
|
+
assert.ok(caps.runtime_providers.providers.find((p) => p.provider === 'cloud-hypervisor')?.capability_constraints?.[0]?.excludes?.includes('instance.restore'), 'provider VFIO constraint preserved');
|
|
223
|
+
const hostProvider = caps.runtime_providers.providers.find((p) => p.provider === 'host');
|
|
224
|
+
const dockerProvider = caps.runtime_providers.providers.find((p) => p.provider === 'docker');
|
|
225
|
+
assert.ok(hostProvider?.platforms?.includes('darwin/arm64'), 'Apple Silicon host runtime discovery is proxied');
|
|
226
|
+
assert.equal(hostProvider?.posture?.host_architecture, 'arm64', 'Apple Silicon host architecture is preserved');
|
|
227
|
+
assert.equal(dockerProvider?.engine, 'Docker Desktop', 'Docker Desktop runtime posture is proxied');
|
|
228
|
+
assert.equal(dockerProvider?.posture?.host_platform, 'darwin', 'Docker Desktop host platform is preserved');
|
|
229
|
+
assert.equal(localLibvirtFallbackAllowed('darwin', undefined), false, 'virsh fallback is not automatic on macOS');
|
|
230
|
+
assert.equal(localLibvirtFallbackAllowed('darwin', '1'), true, 'virsh fallback can be explicitly enabled for local development');
|
|
231
|
+
assert.equal(localLibvirtFallbackAllowed('linux', undefined), true, 'Linux bridge hosts retain local virsh fallback');
|
|
193
232
|
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
233
|
+
const vm = inv.instances.find((i) => i.provider === 'cloud-hypervisor');
|
|
234
|
+
assert.ok(vm, 'provider-aware VM inventory row present');
|
|
235
|
+
const fastStartAccepted = await (await f(`/api/instances/${encodeURIComponent(vm.id)}/snapshot`, {
|
|
236
|
+
method: 'POST',
|
|
237
|
+
headers: { 'content-type': 'application/json' },
|
|
238
|
+
body: JSON.stringify({ asset_ref: 'cockpit-smoke-snapshot' }),
|
|
239
|
+
})).json();
|
|
240
|
+
assert.ok(fastStartAccepted.id, 'fast-start proxy returns operation id');
|
|
241
|
+
const fastStartTerminal = await (await f(`/api/operations/${encodeURIComponent(fastStartAccepted.id)}`)).json();
|
|
242
|
+
assert.equal(fastStartTerminal.state, 'succeeded', 'fast-start operation reaches terminal state');
|
|
243
|
+
assert.equal(fastStartTerminal.result.provider, 'cloud-hypervisor', 'fast-start operation preserves provider');
|
|
198
244
|
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
245
|
+
const mcp = await (await f('/api/mcp/discovery')).json();
|
|
246
|
+
assert.equal(mcp.enabled, true, 'MCP discovery enabled');
|
|
247
|
+
assert.equal(mcp.endpoint?.path, '/mcp', 'MCP endpoint path surfaced');
|
|
248
|
+
assert.equal(mcp.endpoint?.mcp_session_id, false, 'MCP discovery is stateless/no session id');
|
|
249
|
+
assert.ok(mcp.tools.some((tool) => tool.name === 'list_sandboxes'), 'MCP tools surfaced');
|
|
250
|
+
assert.ok(mcp.resource_templates.some((template) => template.uriTemplate === 'sandbox://sessions/{session_id}/screen'), 'MCP resource templates surfaced');
|
|
251
|
+
assert.equal((await f('/api/mcp', { method: 'POST', body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/list' }) })).status, 503, 'MCP proxy fail-closed without token file');
|
|
252
|
+
const gatedReconnect = await f(`/api/instances/${DEFAULT_INSTANCE}/reconnect`, { method: 'POST' });
|
|
253
|
+
assert.equal(gatedReconnect.status, 409, 'Docker reconnect fallback is gated unless local dev flag is set');
|
|
254
|
+
assert.equal((await gatedReconnect.json()).error, 'local_docker_fallback_disabled', 'Docker reconnect fallback error is explicit');
|
|
205
255
|
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
256
|
+
// Bridge registry HTTP/argv contract; actual CLI ranking/show integration remains separate.
|
|
257
|
+
const cap = await (await f("/api/capabilities?q=" + encodeURIComponent("deploy production") + "&limit=4")).json();
|
|
258
|
+
assert.ok(Array.isArray(cap.results) && cap.results.length >= 1, 'discover returns results');
|
|
259
|
+
const hit = cap.results.find((r) => r.name === 'flow-deploy-to-production');
|
|
260
|
+
assert.ok(hit, 'flow-deploy-to-production discoverable');
|
|
261
|
+
assert.ok(hit.name && hit.type, 'result carries name+type for show');
|
|
262
|
+
const shown = await (await f("/api/show?type=skill&name=flow-deploy-to-production")).json();
|
|
263
|
+
assert.match(shown.body, /name:\s*flow-deploy-to-production/, 'show returns the skill body');
|
|
264
|
+
assert.equal((await f("/api/capabilities")).status, 400, 'capabilities requires q');
|
|
265
|
+
// show by discovered PATH — deterministic, sidesteps ambiguous same-named artifacts (#1643)
|
|
266
|
+
if (hit.path) {
|
|
267
|
+
const shownByPath = await (await f(`/api/show?path=${encodeURIComponent(hit.path)}`)).json();
|
|
268
|
+
assert.match(shownByPath.body, /name:\s*flow-deploy-to-production/, 'show-by-path returns the body');
|
|
269
|
+
assert.equal(shownByPath.path, hit.path, 'show-by-path echoes the resolved path');
|
|
270
|
+
} else {
|
|
271
|
+
assert.ok(hit.id, 'pathless discover result carries a stable id');
|
|
272
|
+
}
|
|
273
|
+
// a missing artifact is a 4xx, never a 502 (ambiguous/not-found map to operator-correctable input)
|
|
274
|
+
assert.equal((await f('/api/show?type=agent&name=__definitely_not_a_real_artifact__')).status, 404, 'unknown artifact -> 404 not 502');
|
|
275
|
+
// a path outside the AIWG corpus is refused (no traversal)
|
|
276
|
+
assert.equal((await f(`/api/show?path=${encodeURIComponent('/etc/passwd')}`)).status, 400, 'path outside corpus -> 400');
|
|
215
277
|
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
278
|
+
// contribution model: actions INJECT a command into a session — the Cockpit never
|
|
279
|
+
// runs the CLI (adr-cockpit-session-control-not-cli-runner) (#1591)
|
|
280
|
+
const contrib = await (await f("/api/contributions")).json();
|
|
281
|
+
assert.ok(contrib.sources.some((s) => s.id === 'aiwg-core'), 'aiwg-core contribution loaded');
|
|
282
|
+
const audit = contrib.actions.find((a) => a.id === 'audit-issues');
|
|
283
|
+
assert.ok(audit && typeof audit.inject.command === 'string', 'audit-issues declares an inject command');
|
|
284
|
+
assert.match(audit.inject.command, /issue-audit/, 'audit-issues injects the issue-audit command');
|
|
285
|
+
// the spawn-aiwg run endpoint is removed
|
|
286
|
+
assert.equal((await f("/api/actions/audit-issues/run", { method: 'POST' })).status, 404, 'action run endpoint gone (no Bridge CLI run for actions)');
|
|
219
287
|
|
|
220
|
-
|
|
221
|
-
|
|
288
|
+
// management: lifecycle (UC-012)
|
|
289
|
+
const stoppedId = '9e8d7c6b-5a4f-4e3d-8c2b-1a0f9e8d7c6b';
|
|
290
|
+
assert.equal((await (await f(`/api/instances/${stoppedId}/start`, { method: 'POST' })).json()).state, 'running', 'start -> running');
|
|
291
|
+
assert.equal((await (await f(`/api/instances/${stoppedId}/stop`, { method: 'POST' })).json()).state, 'stopped', 'stop -> stopped');
|
|
222
292
|
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
assert.ok(lib1.library.some((a) => a.name === hit.name), 'cloned asset appears in the user library');
|
|
230
|
-
assert.equal((await f(`/api/library/${encodeURIComponent(hit.name)}`, { method: 'DELETE' })).status, 200, 'library delete 200');
|
|
231
|
-
const lib2 = await (await f('/api/library')).json();
|
|
232
|
-
assert.ok(!lib2.library.some((a) => a.name === hit.name), 'deleted asset removed from library');
|
|
233
|
-
// a path that escapes the library is refused
|
|
234
|
-
assert.equal((await f('/api/library/..%2f..%2fevil', { method: 'DELETE' })).status, 404, 'escape attempt refused');
|
|
293
|
+
// management: cancel a running task
|
|
294
|
+
const before = await (await f('/api/running')).json();
|
|
295
|
+
const victim = before.running[0];
|
|
296
|
+
assert.equal((await f(`/api/tasks/${victim.instance_id}/${victim.task_id}/cancel`, { method: 'POST' })).status, 200, 'task cancel 200');
|
|
297
|
+
const after = await (await f('/api/running')).json();
|
|
298
|
+
assert.ok(after.count < before.count, 'cancel removed a running task');
|
|
235
299
|
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
300
|
+
// approval inbox (UC-009)
|
|
301
|
+
const pend = await (await f('/api/approvals?status=pending')).json();
|
|
302
|
+
assert.equal(pend.derived, 'per-instance A2A input-required tasks', 'approvals derive from A2A tasks');
|
|
303
|
+
assert.ok(pend.approvals.length >= 1, 'pending approvals seeded');
|
|
304
|
+
const approvalId = pend.approvals[0].id;
|
|
305
|
+
const apr = await (await f(`/api/approvals/${encodeURIComponent(approvalId)}?decision=approve`, { method: 'POST' })).json();
|
|
306
|
+
assert.equal(apr.status.state, 'completed', 'approval response completes the task');
|
|
307
|
+
const pend2 = await (await f('/api/approvals?status=pending')).json();
|
|
308
|
+
assert.equal(pend2.approvals.length, pend.approvals.length - 1, 'approved item leaves the queue');
|
|
240
309
|
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
// (the Vite '</head>'-in-comment gotcha) must not count as "referenced".
|
|
248
|
-
const live = html.replace(/<!--[\s\S]*?-->/g, '');
|
|
249
|
-
const shell = /assets\//.test(html) ? 'react' : 'legacy';
|
|
250
|
-
if (shell === 'react') {
|
|
251
|
-
const asset = live.match(/<script[^>]+type="module"[^>]+src="([^"]*assets\/[^"]+\.js)"/);
|
|
252
|
-
assert.ok(asset, 'React build present → module bundle must be referenced outside comments');
|
|
253
|
-
assert.equal((await fetch(base + asset[1].replace(/^\.\//, '/'))).status, 200, 'built React bundle served');
|
|
254
|
-
}
|
|
310
|
+
// cost rollup (UC-010)
|
|
311
|
+
const cost = await (await f('/api/cost')).json();
|
|
312
|
+
assert.ok(cost.total.usd > 0 && cost.per_instance.length >= 1, 'cost rollup present');
|
|
313
|
+
|
|
314
|
+
// destroy
|
|
315
|
+
assert.equal((await (await f(`/api/instances/${stoppedId}`, { method: 'DELETE' })).json()).destroyed, stoppedId, 'destroy returns id');
|
|
255
316
|
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
317
|
+
// Real clone/list/delete of an owned canonical-content fixture, with fresh ownership.
|
|
318
|
+
assert.equal(await readFile(skillPath, 'utf8'), shown.body, 'owned clone fixture preserves canonical shown skill bytes');
|
|
319
|
+
await exerciseLibraryRoundTrip({ f, sourcePath: skillPath, name: 'cockpit-smoke-' + randomUUID() });
|
|
320
|
+
// a path that escapes the library is refused
|
|
321
|
+
assert.equal((await f('/api/library/..%2f..%2fevil', { method: 'DELETE' })).status, 404, 'escape attempt refused');
|
|
322
|
+
|
|
323
|
+
// start a session (onboarding primary verb): create + issue a ws attach_url
|
|
324
|
+
const started = await (await f('/api/instances/550e8400-e29b-41d4-a716-446655440000/sessions', { method: 'POST' })).json();
|
|
325
|
+
assert.match(started.id ?? '', /^sess-/, 'start-session returns a new session id');
|
|
326
|
+
assert.match(started.attach_url ?? '', /\/sessions\/sess-[^/]+\/attach\/[A-Za-z0-9_-]+$/, 'start-session issues a proxied ws attach_url');
|
|
327
|
+
|
|
328
|
+
// app shell is served without reusable credential material.
|
|
329
|
+
const html = await (await fetch(base + "/")).text();
|
|
330
|
+
assert.match(html, /AIWG.?Cockpit/i, 'app title rendered');
|
|
331
|
+
assert.ok(!html.includes(bridge.cockpitToken), 'root does not inject the control token');
|
|
332
|
+
assert.ok(!html.includes('__COCKPIT_TOKEN__'), 'root has no legacy token bootstrap global');
|
|
333
|
+
// strip HTML comments BEFORE matching — a module script trapped inside a comment
|
|
334
|
+
// (the Vite '</head>'-in-comment gotcha) must not count as "referenced".
|
|
335
|
+
const live = html.replace(/<!--[\s\S]*?-->/g, '');
|
|
336
|
+
const shell = /assets\//.test(html) ? 'react' : 'legacy';
|
|
337
|
+
if (shell === 'react') {
|
|
338
|
+
const asset = live.match(/<script[^>]+type="module"[^>]+src="([^"]*assets\/[^"]+\.js)"/);
|
|
339
|
+
assert.ok(asset, 'React build present → module bundle must be referenced outside comments');
|
|
340
|
+
assert.equal((await fetch(base + asset[1].replace(/^\.\//, '/'))).status, 200, 'built React bundle served');
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
console.log(`SMOKE OK — inventory(4) + running(${run.count}) + sessions(demo-shell) + mcp(${mcp.tools.length} tools) + registry(CLI contract fixture→${cap.results.length}) + contrib(${contrib.actions.length}) + shell(${shell})`);
|
|
344
|
+
}, options);
|
|
260
345
|
}
|
|
346
|
+
|
|
347
|
+
if (isDirectExecution(import.meta.url)) await runBridgeSmoke();
|