@thebackstoryis/engineering-with-ai 0.2.9 → 0.3.0
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/Docs/README.md +2 -0
- package/Docs/autonomous-intent-delivery.md +37 -0
- package/Docs/guide-catalogue.md +2 -0
- package/Docs/quality/persona-driven-test-scenarios.md +6 -0
- package/README.md +17 -0
- package/config/autonomy-worker.schema.json +70 -0
- package/config/autonomy.schema.json +25 -0
- package/package.json +3 -3
- package/public/app.js +7 -0
- package/public/autonomy.js +470 -0
- package/public/index.html +26 -0
- package/public/styles.css +64 -0
- package/skills-src/ewai-autonomy/SKILL.md +18 -0
- package/skills-src/ewai-autonomy/agents/openai.yaml +4 -0
- package/skills-src/ewai-deliver/SKILL.md +2 -0
- package/skills-src/ewai-test-scenarios/references/scenario-contract.md +6 -0
- package/src/autonomy-phase-contracts.mjs +88 -0
- package/src/autonomy-worker.mjs +15 -0
- package/src/autonomy.mjs +199 -0
- package/src/checkin.mjs +30 -6
- package/src/cli.mjs +67 -0
- package/src/delivery-gates.mjs +8 -1
- package/src/delivery.mjs +74 -3
- package/src/design-system-application.mjs +28 -0
- package/src/runtime/afk-conductor.mjs +191 -71
- package/src/runtime/autonomy-operations.mjs +250 -0
- package/src/runtime/autonomy-supervisor.mjs +426 -0
- package/src/runtime/autonomy-workers.mjs +333 -0
- package/src/runtime/autonomy-workspace.mjs +310 -0
- package/src/runtime/dashboard-actions.mjs +69 -0
- package/src/runtime/dashboard-server.mjs +25 -0
- package/src/runtime/database.mjs +25 -6
- package/src/runtime/intent-ownership.mjs +272 -0
- package/src/runtime/mcp-server.mjs +38 -0
- package/src/runtime/provider-adapters.mjs +365 -7
- package/src/runtime/work.mjs +21 -5
- package/src/test-scenarios.mjs +173 -20
|
@@ -1,9 +1,15 @@
|
|
|
1
|
-
import { accessSync, constants, createWriteStream, mkdirSync } from 'node:fs';
|
|
1
|
+
import { accessSync, constants, createWriteStream, mkdirSync, existsSync, realpathSync, lstatSync, openSync, readSync, closeSync, fstatSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
|
2
2
|
import { delimiter, isAbsolute, resolve } from 'node:path';
|
|
3
|
-
import { spawn } from 'node:child_process';
|
|
3
|
+
import { spawn, spawnSync } from 'node:child_process';
|
|
4
|
+
import { createHash } from 'node:crypto';
|
|
5
|
+
import { release, tmpdir } from 'node:os';
|
|
6
|
+
import { createServer } from 'node:http';
|
|
4
7
|
import { CONTEXT_ESTIMATE_METHOD, estimateContextTokens } from './context-assembly.mjs';
|
|
5
8
|
|
|
6
9
|
export const AFK_PROVIDERS = Object.freeze(['codex', 'claude', 'antigravity']);
|
|
10
|
+
// Retain owned child handles after an uncertain stop; do not turn a missing
|
|
11
|
+
// close event into success or lose the ability to observe a later exit.
|
|
12
|
+
const unsettledProcesses = new Set();
|
|
7
13
|
|
|
8
14
|
export function measureProviderPrompt(prompt) {
|
|
9
15
|
const measurement = estimateContextTokens(prompt);
|
|
@@ -62,6 +68,7 @@ function claudeTools(task, mode) {
|
|
|
62
68
|
|
|
63
69
|
export function buildProviderInvocation(provider, input = {}) {
|
|
64
70
|
if (!AFK_PROVIDERS.includes(provider)) throw new Error(`Unsupported AFK provider: ${provider}`);
|
|
71
|
+
assertAfkMode(input.mode);
|
|
65
72
|
const mode = input.mode === 'review' ? 'review' : 'implementation';
|
|
66
73
|
const cwd = resolve(input.cwd);
|
|
67
74
|
const model = String(input.model ?? '').trim();
|
|
@@ -109,6 +116,11 @@ export function buildProviderInvocation(provider, input = {}) {
|
|
|
109
116
|
}
|
|
110
117
|
|
|
111
118
|
export function invokeProvider(invocation, options = {}) {
|
|
119
|
+
assertAfkMode(invocation.mode);
|
|
120
|
+
if (options.signal !== undefined && !(options.signal instanceof AbortSignal)) throw new Error('Invalid host cancellation signal.');
|
|
121
|
+
if (options.signal?.aborted) return Promise.resolve({ provider: invocation.provider, exitCode: -1,
|
|
122
|
+
signal: '', cancelled: true, timedOut: false, executionStopped: true, processGroupStopped: true,
|
|
123
|
+
durationMs: 0, output: '', logPath: invocation.logPath, providerUsage: null });
|
|
112
124
|
mkdirSync(resolve(invocation.logPath, '..'), { recursive: true });
|
|
113
125
|
const output = createWriteStream(invocation.logPath, { flags: 'a' });
|
|
114
126
|
const startedAt = Date.now();
|
|
@@ -120,10 +132,29 @@ export function invokeProvider(invocation, options = {}) {
|
|
|
120
132
|
env: options.env ?? process.env,
|
|
121
133
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
122
134
|
shell: false,
|
|
135
|
+
detached: process.platform !== 'win32',
|
|
123
136
|
});
|
|
124
137
|
options.onStart?.(child.pid);
|
|
125
138
|
let captured = '';
|
|
126
139
|
let timedOut = false;
|
|
140
|
+
let cancelled = false, killTimer, stopDeadline, settled = false;
|
|
141
|
+
const kill = signal => {
|
|
142
|
+
try { if (process.platform === 'win32') child.kill(signal); else process.kill(-child.pid, signal); } catch {}
|
|
143
|
+
};
|
|
144
|
+
const stop = () => {
|
|
145
|
+
if (settled) return;
|
|
146
|
+
kill('SIGTERM'); killTimer ??= setTimeout(() => kill('SIGKILL'), 250);
|
|
147
|
+
stopDeadline ??= setTimeout(() => {
|
|
148
|
+
if (settled) return; settled = true; clearTimeout(timeout); clearTimeout(killTimer);
|
|
149
|
+
options.signal?.removeEventListener('abort', cancel);
|
|
150
|
+
unsettledProcesses.add(child); child.once('close', () => unsettledProcesses.delete(child));
|
|
151
|
+
child.stdin.destroy(); child.stdout.destroy(); child.stderr.destroy(); child.unref(); output.end();
|
|
152
|
+
resolvePromise({ provider: invocation.provider, status: 'unknown', exitCode: -1, signal: '',
|
|
153
|
+
cancelled, timedOut, executionStopped: false, processGroupStopped: false, output: '',
|
|
154
|
+
durationMs: Date.now() - startedAt, logPath: invocation.logPath, providerUsage: null });
|
|
155
|
+
}, 1000);
|
|
156
|
+
};
|
|
157
|
+
const cancel = () => { cancelled = true; stop(); };
|
|
127
158
|
const capture = (chunk) => {
|
|
128
159
|
output.write(chunk);
|
|
129
160
|
captured = `${captured}${chunk.toString('utf8')}`.slice(-maxCapture);
|
|
@@ -131,23 +162,34 @@ export function invokeProvider(invocation, options = {}) {
|
|
|
131
162
|
child.stdout.on('data', capture);
|
|
132
163
|
child.stderr.on('data', capture);
|
|
133
164
|
child.once('error', (error) => {
|
|
165
|
+
settled = true; clearTimeout(timeout); clearTimeout(killTimer); clearTimeout(stopDeadline); options.signal?.removeEventListener('abort', cancel);
|
|
134
166
|
output.end();
|
|
135
167
|
reject(error);
|
|
136
168
|
});
|
|
137
169
|
const timeout = setTimeout(() => {
|
|
138
170
|
timedOut = true;
|
|
139
|
-
|
|
140
|
-
setTimeout(() => child.kill('SIGKILL'), 5_000).unref();
|
|
171
|
+
stop();
|
|
141
172
|
}, invocation.timeoutMs);
|
|
142
173
|
timeout.unref();
|
|
143
|
-
child.once('close', (exitCode, signal) => {
|
|
144
|
-
|
|
174
|
+
child.once('close', async (exitCode, signal) => {
|
|
175
|
+
if (settled) return;
|
|
176
|
+
settled = true; clearTimeout(timeout); clearTimeout(killTimer); clearTimeout(stopDeadline); options.signal?.removeEventListener('abort', cancel);
|
|
177
|
+
let processGroupStopped = false;
|
|
178
|
+
if (process.platform !== 'win32') {
|
|
179
|
+
try { process.kill(-child.pid, 0); kill('SIGKILL'); }
|
|
180
|
+
catch (error) { processGroupStopped = error.code === 'ESRCH'; }
|
|
181
|
+
}
|
|
145
182
|
output.end();
|
|
146
183
|
resolvePromise({
|
|
147
184
|
provider: invocation.provider,
|
|
148
185
|
exitCode: exitCode ?? -1,
|
|
149
186
|
signal: signal ?? '',
|
|
150
187
|
timedOut,
|
|
188
|
+
cancelled,
|
|
189
|
+
processGroupStopped,
|
|
190
|
+
// AFK tools can launch detached descendants. Group disappearance alone
|
|
191
|
+
// is not proof of full-tree termination after cancellation or timeout.
|
|
192
|
+
executionStopped: !cancelled && !timedOut && processGroupStopped,
|
|
151
193
|
durationMs: Date.now() - startedAt,
|
|
152
194
|
logPath: invocation.logPath,
|
|
153
195
|
output: captured,
|
|
@@ -158,6 +200,322 @@ export function invokeProvider(invocation, options = {}) {
|
|
|
158
200
|
),
|
|
159
201
|
});
|
|
160
202
|
});
|
|
161
|
-
|
|
203
|
+
options.signal?.addEventListener('abort', cancel, { once: true });
|
|
204
|
+
if (options.signal?.aborted) cancel();
|
|
205
|
+
child.stdin.on('error', () => {});
|
|
206
|
+
child.stdin.end(cancelled ? '' : invocation.prompt);
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function assertAfkMode(mode) {
|
|
211
|
+
// Proposal workers have a different authority and output boundary. An
|
|
212
|
+
// unrecognised mode must never silently acquire implementation permissions.
|
|
213
|
+
if (mode === undefined || mode === 'implementation' || mode === 'review') return;
|
|
214
|
+
const error = new Error('This mode is unavailable through the AFK provider transport.');
|
|
215
|
+
error.code = 'phase-provider-mode-unavailable';
|
|
216
|
+
throw error;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// Separate from AFK: capabilities are host-created, process-local handles, not
|
|
220
|
+
// serialisable worker/browser supplied executable paths or permission flags.
|
|
221
|
+
const phaseCapabilities = new WeakMap();
|
|
222
|
+
const unavailablePhase = code => ({ status: 'unavailable', code, requiresHuman: true });
|
|
223
|
+
const phaseVersions = Object.freeze({ claude: '2.1.257 (Claude Code)', sandbox: 'macos-seatbelt-phase-v1' });
|
|
224
|
+
const phaseEnv = () => ({ PATH: process.env.PATH ?? '', LANG: 'en_US.UTF-8' });
|
|
225
|
+
|
|
226
|
+
function installedExecutable(command) {
|
|
227
|
+
for (const directory of (process.env.PATH ?? '').split(delimiter).filter(Boolean)) {
|
|
228
|
+
const path = resolve(directory, command);
|
|
229
|
+
try { accessSync(path, constants.X_OK); return realpathSync(path); } catch {}
|
|
230
|
+
}
|
|
231
|
+
throw new Error('phase-provider-missing');
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function executableDigest(path) {
|
|
235
|
+
const before = lstatSync(path);
|
|
236
|
+
// Package managers may hard-link installed executables. They are trusted
|
|
237
|
+
// host code, unlike untrusted proposal files; bind their bytes and identity.
|
|
238
|
+
if (!before.isFile() || before.size > 512 * 1024 * 1024) throw new Error('phase-provider-identity-invalid');
|
|
239
|
+
const fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW), hash = createHash('sha256'), buffer = Buffer.alloc(1024 * 1024);
|
|
240
|
+
try {
|
|
241
|
+
let bytes; while ((bytes = readSync(fd, buffer, 0, buffer.length, null))) hash.update(buffer.subarray(0, bytes));
|
|
242
|
+
const after = fstatSync(fd), current = lstatSync(path);
|
|
243
|
+
if (before.ino !== after.ino || before.dev !== after.dev || before.size !== after.size || before.mtimeMs !== after.mtimeMs
|
|
244
|
+
|| after.nlink !== before.nlink || current.ino !== after.ino || current.mtimeMs !== after.mtimeMs) throw new Error('phase-provider-identity-changed');
|
|
245
|
+
return hash.digest('hex');
|
|
246
|
+
} finally { closeSync(fd); }
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
export function preparePhaseProvider(provider) {
|
|
250
|
+
// Additional providers/platforms stay unavailable until the same executable
|
|
251
|
+
// and configuration-isolation conformance has actually been demonstrated.
|
|
252
|
+
if (provider !== 'claude' || process.platform !== 'darwin') return unavailablePhase('phase-provider-mode-unavailable');
|
|
253
|
+
try {
|
|
254
|
+
const executable = installedExecutable('claude'), sandbox = realpathSync('/usr/bin/sandbox-exec');
|
|
255
|
+
const version = path => {
|
|
256
|
+
const result = spawnSync(path, ['--version'], { env: phaseEnv(), encoding: 'utf8', timeout: 5000, maxBuffer: 1024 });
|
|
257
|
+
if (result.status !== 0 || result.error) throw new Error('phase-provider-version-unavailable');
|
|
258
|
+
return result.stdout.trim();
|
|
259
|
+
};
|
|
260
|
+
if (version(executable) !== phaseVersions.claude) return unavailablePhase('phase-provider-version-unverified');
|
|
261
|
+
const hashes = [executableDigest(executable), executableDigest(sandbox)];
|
|
262
|
+
const handle = Object.freeze({ status: 'requires-conformance', provider, version: phaseVersions.claude,
|
|
263
|
+
sandboxVersion: phaseVersions.sandbox,
|
|
264
|
+
identityDigest: createHash('sha256').update(JSON.stringify([hashes, release(), process.arch, phaseVersions.sandbox])).digest('hex') });
|
|
265
|
+
phaseCapabilities.set(handle, { executable, sandbox, hashes, verified: false });
|
|
266
|
+
return handle;
|
|
267
|
+
} catch { return unavailablePhase('phase-provider-identity-unavailable'); }
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function currentPhaseCapability(handle, requireVerified = true) {
|
|
271
|
+
const capability = phaseCapabilities.get(handle);
|
|
272
|
+
if (!capability || requireVerified && !capability.verified) throw new Error('phase-provider-conformance-required');
|
|
273
|
+
if (executableDigest(capability.executable) !== capability.hashes[0] || executableDigest(capability.sandbox) !== capability.hashes[1]) {
|
|
274
|
+
capability.verified = false; throw new Error('phase-provider-identity-changed');
|
|
275
|
+
}
|
|
276
|
+
return capability;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function restrictedPhaseArgs(runtime, command, args, fixture) {
|
|
280
|
+
// One kernel policy covers every restriction; nested Seatbelt initialisation
|
|
281
|
+
// is not supported. Denying fork also denies posix_spawn, so a child cannot
|
|
282
|
+
// detach into a new session and outlive the captured process.
|
|
283
|
+
const port = fixture?.url ? Number(new URL(fixture.url).port) : null;
|
|
284
|
+
const policy = `(version 1)(allow default)(deny file-write*)`
|
|
285
|
+
+ `(allow file-write* (subpath ${JSON.stringify(runtime)}))(deny process-fork)`
|
|
286
|
+
+ (fixture ? `(deny network*)${port ? `(allow network-outbound (remote ip "localhost:${port}"))` : ''}` : '');
|
|
287
|
+
return ['-p', policy, command, ...args];
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
const restrictedClaudeArgs = () => ['--safe-mode', '--bare', '--restricted', '--tools', '', '--strict-mcp-config',
|
|
291
|
+
'--mcp-config', '{"mcpServers":{}}', '--setting-sources', '', '--no-chrome', '--disable-slash-commands',
|
|
292
|
+
'--no-session-persistence', '--system-prompt-snapshot', 'off', '--permission-mode', 'dontAsk',
|
|
293
|
+
'--max-turns', '1', '--print', '--output-format', 'json'];
|
|
294
|
+
|
|
295
|
+
async function captureRestrictedProcess(capability, input, fixture = null) {
|
|
296
|
+
const cancelledBeforeSpawn = () => ({ status: 'cancelled', exitCode: -1, signal: null,
|
|
297
|
+
executionStopped: true, spawned: false, output: '', bytes: 0 });
|
|
298
|
+
if (input.signal?.aborted) return cancelledBeforeSpawn();
|
|
299
|
+
const directory = realpathSync(mkdtempSync(resolve(tmpdir(), 'ewai-phase-process-')));
|
|
300
|
+
const identity = lstatSync(directory), runtime = resolve(directory, 'runtime');
|
|
301
|
+
mkdirSync(runtime, { mode: 0o700 });
|
|
302
|
+
if (fixture?.isolationProbe) {
|
|
303
|
+
// Deliberately hostile *fixture* configuration. A loaded hook/MCP server
|
|
304
|
+
// would contact the local observer; no real provider endpoint is involved.
|
|
305
|
+
const script = `require("node:http").get(${JSON.stringify(`${fixture.url}/forbidden-hook`)})`;
|
|
306
|
+
const hook = { hooks: { SessionStart: [{ hooks: [{ type: 'command', command: `${JSON.stringify(process.execPath)} -e '${script}'` }] }] } };
|
|
307
|
+
mkdirSync(resolve(directory, '.claude'));
|
|
308
|
+
for (const path of [resolve(directory, '.claude/settings.json'), resolve(runtime, 'settings.json')]) writeFileSync(path, JSON.stringify(hook), { mode: 0o600 });
|
|
309
|
+
writeFileSync(resolve(directory, '.mcp.json'), JSON.stringify({ mcpServers: { forbidden: { type: 'http', url: `${fixture.url}/forbidden-mcp` } } }), { mode: 0o600 });
|
|
310
|
+
writeFileSync(resolve(directory, 'CLAUDE.md'), 'EWAI_UNTRUSTED_INSTRUCTIONS_CANARY', { mode: 0o600 });
|
|
311
|
+
}
|
|
312
|
+
const env = { ...phaseEnv(), CLAUDE_CONFIG_DIR: runtime, CLAUDE_CODE_TMPDIR: runtime, TMPDIR: runtime,
|
|
313
|
+
CLAUDE_CODE_SKIP_PROMPT_HISTORY: '1', CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: '1',
|
|
314
|
+
DISABLE_TELEMETRY: '1', DISABLE_ERROR_REPORTING: '1',
|
|
315
|
+
ANTHROPIC_API_KEY: fixture ? 'ewai-fixture-not-a-real-key' : process.env.ANTHROPIC_API_KEY };
|
|
316
|
+
if (fixture?.url) env.ANTHROPIC_BASE_URL = fixture.url;
|
|
317
|
+
const command = fixture?.command ?? capability.executable, args = fixture?.args ?? restrictedClaudeArgs();
|
|
318
|
+
const launchCommand = capability.sandbox;
|
|
319
|
+
const launchArgs = restrictedPhaseArgs(runtime, command, args, fixture);
|
|
320
|
+
let stopped = false;
|
|
321
|
+
try {
|
|
322
|
+
const result = await new Promise(resolvePromise => {
|
|
323
|
+
let child, timer, killTimer, stopDeadline, terminal = null, bytes = 0, output = [], settled = false;
|
|
324
|
+
const kill = signal => { if (child?.pid) try { process.kill(-child.pid, signal); } catch {} };
|
|
325
|
+
const stop = status => {
|
|
326
|
+
if (settled) return;
|
|
327
|
+
terminal ??= status; kill('SIGTERM'); killTimer ??= setTimeout(() => kill('SIGKILL'), 250);
|
|
328
|
+
stopDeadline ??= setTimeout(() => {
|
|
329
|
+
if (settled) return; settled = true; clearTimeout(timer); clearTimeout(killTimer);
|
|
330
|
+
input.signal?.removeEventListener('abort', cancel);
|
|
331
|
+
unsettledProcesses.add(child); child.once('close', () => unsettledProcesses.delete(child));
|
|
332
|
+
child.stdin.destroy(); child.stdout.destroy(); child.stderr.destroy(); child.unref();
|
|
333
|
+
resolvePromise({ status: 'unknown', exitCode: -1, signal: null, executionStopped: false, output: '', bytes });
|
|
334
|
+
}, 1000);
|
|
335
|
+
};
|
|
336
|
+
const cancel = () => stop('cancelled');
|
|
337
|
+
const finish = async (exitCode, signal) => {
|
|
338
|
+
if (settled) return; settled = true; clearTimeout(timer); clearTimeout(killTimer); clearTimeout(stopDeadline);
|
|
339
|
+
input.signal?.removeEventListener('abort', cancel);
|
|
340
|
+
// A surviving process group is not a confirmed stop. Kill it and do
|
|
341
|
+
// not return a proposal; preserve its private workspace for recovery.
|
|
342
|
+
stopped = !child?.pid;
|
|
343
|
+
for (let attempt = 0; child?.pid && attempt < 20; attempt += 1) {
|
|
344
|
+
try { process.kill(-child.pid, 0); kill('SIGKILL'); }
|
|
345
|
+
catch (error) { stopped = error.code === 'ESRCH'; break; }
|
|
346
|
+
// macOS may retain a reparented zombie briefly. A zombie cannot
|
|
347
|
+
// execute or hold file descriptors; do not confuse it with a worker.
|
|
348
|
+
const processes = spawnSync('/bin/ps', ['-o', 'pgid=,stat=', '-g', String(child.pid)],
|
|
349
|
+
{ encoding: 'utf8', timeout: 500, maxBuffer: 4096, env: phaseEnv() });
|
|
350
|
+
const rows = (processes.stdout ?? '').trim().split('\n').filter(Boolean);
|
|
351
|
+
if (!processes.error && rows.length && rows.every(row => {
|
|
352
|
+
const [group, state] = row.trim().split(/\s+/); return group === String(child.pid) && /^Z/.test(state ?? '');
|
|
353
|
+
})) { stopped = true; break; }
|
|
354
|
+
await new Promise(resolveWait => setTimeout(resolveWait, 25));
|
|
355
|
+
}
|
|
356
|
+
if (input.signal?.aborted) terminal ??= 'cancelled';
|
|
357
|
+
resolvePromise({ status: !stopped ? 'unknown' : terminal ?? (exitCode === 0 ? 'complete' : 'failed'),
|
|
358
|
+
exitCode: exitCode ?? -1, signal: signal ?? null, executionStopped: stopped,
|
|
359
|
+
output: !terminal && stopped ? Buffer.concat(output).toString('utf8') : '', bytes });
|
|
360
|
+
};
|
|
361
|
+
if (input.signal?.aborted) { stopped = true; resolvePromise(cancelledBeforeSpawn()); return; }
|
|
362
|
+
try { child = spawn(launchCommand, launchArgs, { cwd: directory, env, detached: true, shell: false, stdio: ['pipe', 'pipe', 'pipe'] }); }
|
|
363
|
+
catch { finish(-1, null); return; }
|
|
364
|
+
const capture = (chunk, stdout) => {
|
|
365
|
+
bytes += chunk.length;
|
|
366
|
+
if (bytes > input.maxBytes) { output = []; stop('truncated'); return; }
|
|
367
|
+
if (stdout && !terminal) output.push(chunk);
|
|
368
|
+
};
|
|
369
|
+
child.stdout.on('data', chunk => capture(chunk, true)); child.stderr.on('data', chunk => capture(chunk, false));
|
|
370
|
+
child.stdin.on('error', () => {});
|
|
371
|
+
child.once('error', () => finish(-1, null)); child.once('close', finish);
|
|
372
|
+
timer = setTimeout(() => stop('timed-out'), input.timeoutMs);
|
|
373
|
+
input.signal?.addEventListener('abort', cancel, { once: true });
|
|
374
|
+
if (input.signal?.aborted) cancel();
|
|
375
|
+
child.stdin.end(terminal ? '' : input.prompt);
|
|
376
|
+
});
|
|
377
|
+
return result;
|
|
378
|
+
} finally {
|
|
379
|
+
if (stopped && existsSync(directory)) {
|
|
380
|
+
const current = lstatSync(directory);
|
|
381
|
+
if (current.isDirectory() && current.ino === identity.ino && current.dev === identity.dev) rmSync(directory, { recursive: true });
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
export async function invokeRestrictedPhaseProvider(handle, input = {}) {
|
|
387
|
+
if (!input || typeof input !== 'object' || Array.isArray(input) || typeof input.prompt !== 'string' || Buffer.byteLength(input.prompt) > 256 * 1024
|
|
388
|
+
|| !Number.isSafeInteger(input.timeoutMs) || input.timeoutMs < 100 || input.timeoutMs > 600000
|
|
389
|
+
|| input.signal !== undefined && !(input.signal instanceof AbortSignal)) return unavailablePhase('phase-provider-input-invalid');
|
|
390
|
+
let capability;
|
|
391
|
+
try { capability = currentPhaseCapability(handle); } catch { return unavailablePhase('phase-provider-conformance-required'); }
|
|
392
|
+
if (!process.env.ANTHROPIC_API_KEY) return unavailablePhase('phase-provider-credential-unavailable');
|
|
393
|
+
const captured = await captureRestrictedProcess(capability, { ...input, maxBytes: 256 * 1024 });
|
|
394
|
+
if (captured.status !== 'complete') return { ...captured, output: '' };
|
|
395
|
+
let envelope;
|
|
396
|
+
try { envelope = JSON.parse(captured.output); } catch { return { status: 'malformed', executionStopped: true, exitCode: 0, output: '' }; }
|
|
397
|
+
if (envelope?.type !== 'result' || envelope.subtype !== 'success' || envelope.is_error !== false || typeof envelope.result !== 'string') {
|
|
398
|
+
return { status: 'failed', executionStopped: true, exitCode: captured.exitCode, output: '' };
|
|
399
|
+
}
|
|
400
|
+
return { status: 'complete', exitCode: 0, executionStopped: true, output: envelope.result,
|
|
401
|
+
providerUsage: normaliseProviderUsage('claude', envelope.usage) };
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
export async function verifyPhaseProviderConformance(handle, control = {}) {
|
|
405
|
+
if (!control || typeof control !== 'object' || Array.isArray(control)
|
|
406
|
+
|| Object.getPrototypeOf(control) !== Object.prototype || Object.keys(control).some(key => !['signal', 'timeoutMs'].includes(key))
|
|
407
|
+
|| control.signal !== undefined && !(control.signal instanceof AbortSignal)
|
|
408
|
+
|| control.timeoutMs !== undefined && (!Number.isSafeInteger(control.timeoutMs) || control.timeoutMs < 100 || control.timeoutMs > 600000)) {
|
|
409
|
+
return { ...unavailablePhase('phase-provider-input-invalid'), executionStopped: true };
|
|
410
|
+
}
|
|
411
|
+
if (control.signal?.aborted) return { ...unavailablePhase('phase-cancelled'), executionStopped: true };
|
|
412
|
+
let capability;
|
|
413
|
+
try { capability = currentPhaseCapability(handle, false); } catch { return unavailablePhase('phase-provider-conformance-required'); }
|
|
414
|
+
capability.verified = false;
|
|
415
|
+
const controller = new AbortController(), cancel = () => controller.abort();
|
|
416
|
+
control.signal?.addEventListener('abort', cancel, { once: true });
|
|
417
|
+
const deadline = setTimeout(cancel, control.timeoutMs ?? 60000);
|
|
418
|
+
let executionStopped = true;
|
|
419
|
+
const capture = async (capability, input, fixture) => {
|
|
420
|
+
if (controller.signal.aborted) throw new Error('phase-cancelled');
|
|
421
|
+
const result = await captureRestrictedProcess(capability, { ...input, signal: controller.signal }, fixture);
|
|
422
|
+
executionStopped &&= result.executionStopped === true;
|
|
423
|
+
if (!executionStopped) throw new Error('phase-execution-unknown');
|
|
424
|
+
if (controller.signal.aborted) throw new Error('phase-cancelled');
|
|
425
|
+
return result;
|
|
426
|
+
};
|
|
427
|
+
const targetRoot = realpathSync(mkdtempSync(resolve(tmpdir(), 'ewai-phase-conformance-'))), target = resolve(targetRoot, 'approval.json');
|
|
428
|
+
const requests = [];
|
|
429
|
+
let forcedTool = false;
|
|
430
|
+
const server = createServer((request, response) => {
|
|
431
|
+
if (request.method === 'GET' && request.url === '/ewai-network-probe') { response.writeHead(204); response.end(); return; }
|
|
432
|
+
if (request.method === 'HEAD' && request.url === '/api/hello') {
|
|
433
|
+
requests.push({ path: '/api/hello', healthProbe: true }); response.writeHead(204); response.end(); return;
|
|
434
|
+
}
|
|
435
|
+
let body = '', bytes = 0;
|
|
436
|
+
request.on('data', chunk => { bytes += chunk.length; if (bytes > 2 * 1024 * 1024) request.destroy(); else body += chunk; });
|
|
437
|
+
request.on('end', () => {
|
|
438
|
+
let input; try { input = JSON.parse(body); } catch { requests.push({ path: request.url }); response.writeHead(400); response.end(); return; }
|
|
439
|
+
requests.push({ path: request.url, tools: (input.tools ?? []).map(tool => tool.name),
|
|
440
|
+
inheritedInstructions: body.includes('EWAI_UNTRUSTED_INSTRUCTIONS_CANARY') });
|
|
441
|
+
response.writeHead(200, { 'content-type': 'text/event-stream' });
|
|
442
|
+
const event = (type, value) => response.write(`event: ${type}\ndata: ${JSON.stringify({ type, ...value })}\n\n`);
|
|
443
|
+
event('message_start', { message: { id: 'msg_ewai_fixture', type: 'message', role: 'assistant', model: 'fixture-model',
|
|
444
|
+
content: [], stop_reason: null, stop_sequence: null, usage: { input_tokens: 1, output_tokens: 0 } } });
|
|
445
|
+
event('content_block_start', { index: 0, content_block: forcedTool
|
|
446
|
+
? { type: 'tool_use', id: 'toolu_ewai_fixture', name: 'Write', input: {} } : { type: 'text', text: '' } });
|
|
447
|
+
event('content_block_delta', { index: 0, delta: forcedTool
|
|
448
|
+
? { type: 'input_json_delta', partial_json: JSON.stringify({ file_path: target, content: 'forged approval' }) }
|
|
449
|
+
: { type: 'text_delta', text: 'EWAI_RESTRICTED_FIXTURE_ONLY' } });
|
|
450
|
+
event('content_block_stop', { index: 0 });
|
|
451
|
+
event('message_delta', { delta: { stop_reason: forcedTool ? 'tool_use' : 'end_turn', stop_sequence: null }, usage: { output_tokens: 1 } });
|
|
452
|
+
event('message_stop', {}); response.end();
|
|
453
|
+
});
|
|
162
454
|
});
|
|
455
|
+
try {
|
|
456
|
+
await new Promise((resolvePromise, reject) => { server.once('error', reject); server.listen(0, '127.0.0.1', resolvePromise); });
|
|
457
|
+
const fixtureUrl = `http://127.0.0.1:${server.address().port}`;
|
|
458
|
+
const network = await capture(capability, { prompt: '', timeoutMs: 5000, maxBytes: 1024 }, {
|
|
459
|
+
url: fixtureUrl, command: process.execPath, args: ['-e',
|
|
460
|
+
'const http=require("node:http"),net=require("node:net");let a=null,b=null;const done=()=>{if(a!==null&&b!==null)process.stdout.write(JSON.stringify({allowed:a,denied:b}))};http.get(process.argv[1]+"/ewai-network-probe",r=>{a=r.statusCode===204;r.resume();done()}).on("error",()=>{a=false;done()});const s=net.connect({host:"127.0.0.2",port:Number(process.argv[2])});s.on("connect",()=>{b=false;s.destroy();done()});s.on("error",e=>{b=["EPERM","EACCES"].includes(e.code);done()});',
|
|
461
|
+
fixtureUrl, String(server.address().port)] });
|
|
462
|
+
if (network.status !== 'complete' || network.output !== '{"allowed":true,"denied":true}') {
|
|
463
|
+
let observed; try { observed = JSON.parse(network.output); } catch {}
|
|
464
|
+
return { ...unavailablePhase('phase-provider-network-confinement-failed'), checks: { networkIsolated: false,
|
|
465
|
+
allowedLoopback: observed?.allowed === true, deniedOtherAddress: observed?.denied === true,
|
|
466
|
+
processComplete: network.status === 'complete' } };
|
|
467
|
+
}
|
|
468
|
+
const denial = await capture(capability, { prompt: '', timeoutMs: 5000, maxBytes: 1024 }, {
|
|
469
|
+
command: process.execPath, args: ['-e', 'try{require("node:fs").writeFileSync(process.argv[1],"forged");process.stdout.write("UNSAFE")}catch(e){process.stdout.write(e.code)}', target] });
|
|
470
|
+
const overflow = await capture(capability, { prompt: '', timeoutMs: 5000, maxBytes: 1024 }, {
|
|
471
|
+
command: process.execPath, args: ['-e', 'process.stdout.write("x".repeat(65536))'] });
|
|
472
|
+
const timeout = await capture(capability, { prompt: '', timeoutMs: 100, maxBytes: 1024 }, {
|
|
473
|
+
command: process.execPath, args: ['-e', 'setTimeout(() => {}, 10000)'] });
|
|
474
|
+
const fork = await capture(capability, { prompt: '', timeoutMs: 5000, maxBytes: 1024 }, {
|
|
475
|
+
command: process.execPath, args: ['-e',
|
|
476
|
+
'try{const c=require("node:child_process").spawn(process.execPath,["-e","process.exit()"],{detached:true,stdio:"ignore"});c.on("error",e=>process.stdout.write(e.code));c.on("spawn",()=>process.stdout.write("UNSAFE"))}catch(e){process.stdout.write(e.code)}'] });
|
|
477
|
+
const primitiveChecks = {
|
|
478
|
+
networkIsolated: true,
|
|
479
|
+
canonicalWritePrevented: denial.status === 'complete' && ['EPERM', 'EACCES'].includes(denial.output) && !existsSync(target),
|
|
480
|
+
outputBoundEnforced: overflow.status === 'truncated' && overflow.output === '' && overflow.executionStopped,
|
|
481
|
+
timeoutEnforced: timeout.status === 'timed-out' && timeout.output === '' && timeout.executionStopped,
|
|
482
|
+
childProcessPrevented: fork.status === 'complete' && ['EPERM', 'EACCES'].includes(fork.output),
|
|
483
|
+
};
|
|
484
|
+
if (!Object.values(primitiveChecks).every(value => value === true)) {
|
|
485
|
+
return { ...unavailablePhase('phase-provider-confinement-failed'), checks: primitiveChecks };
|
|
486
|
+
}
|
|
487
|
+
const model = await capture(capability, { prompt: 'Return the fixture token only.', timeoutMs: 15000, maxBytes: 16384 },
|
|
488
|
+
{ url: fixtureUrl, isolationProbe: true });
|
|
489
|
+
let envelope; try { envelope = JSON.parse(model.output); } catch {}
|
|
490
|
+
forcedTool = true;
|
|
491
|
+
const forged = await capture(capability, { prompt: 'No tools or approvals are authorised.', timeoutMs: 15000, maxBytes: 16384 },
|
|
492
|
+
{ url: fixtureUrl, isolationProbe: true });
|
|
493
|
+
let refused; try { refused = JSON.parse(forged.output); } catch {}
|
|
494
|
+
const checks = {
|
|
495
|
+
...primitiveChecks,
|
|
496
|
+
structuredDraft: model.status === 'complete' && envelope?.type === 'result' && envelope.subtype === 'success' && envelope.is_error === false
|
|
497
|
+
&& envelope.result === 'EWAI_RESTRICTED_FIXTURE_ONLY',
|
|
498
|
+
inheritedCapabilitiesBlocked: requests.filter(request => request.path.startsWith('/v1/messages')).length === 2
|
|
499
|
+
&& requests.every(request => request.healthProbe === true
|
|
500
|
+
|| request.path.startsWith('/v1/messages') && request.tools?.length === 0 && !request.inheritedInstructions),
|
|
501
|
+
forgedToolRefused: forged.executionStopped && refused?.type === 'result' && refused.is_error === true && !existsSync(target),
|
|
502
|
+
};
|
|
503
|
+
capability.verified = Object.values(checks).every(value => value === true);
|
|
504
|
+
currentPhaseCapability(handle, false);
|
|
505
|
+
return capability.verified ? { status: 'verified', provider: 'claude', identityDigest: handle.identityDigest,
|
|
506
|
+
canonicalWritePrevented: true, outputBoundEnforced: true, timeoutEnforced: true, networkIsolated: true, childProcessPrevented: true, toolsExposed: 0, inheritedCapabilitiesBlocked: true,
|
|
507
|
+
forgedToolRefused: true, fixtureServiceOnly: true,
|
|
508
|
+
authority: 'none' } : { ...unavailablePhase('phase-provider-conformance-failed'), checks,
|
|
509
|
+
requests: requests.map(request => ({ route: request.healthProbe ? 'fixture-health' : request.path.startsWith('/v1/messages') ? 'fixture-model'
|
|
510
|
+
: request.path.includes('forbidden-hook') ? 'forbidden-hook' : request.path.includes('forbidden-mcp') ? 'forbidden-mcp' : 'other',
|
|
511
|
+
toolsExposed: request.tools?.length ?? null, inheritedInstructions: request.inheritedInstructions === true })) };
|
|
512
|
+
} catch (error) {
|
|
513
|
+
capability.verified = false;
|
|
514
|
+
return { ...unavailablePhase(['phase-cancelled', 'phase-execution-unknown'].includes(error.message)
|
|
515
|
+
? error.message : 'phase-provider-conformance-failed'), executionStopped };
|
|
516
|
+
} finally {
|
|
517
|
+
clearTimeout(deadline); control.signal?.removeEventListener('abort', cancel);
|
|
518
|
+
server.closeAllConnections(); await new Promise(resolvePromise => server.close(resolvePromise));
|
|
519
|
+
if (executionStopped) rmSync(targetRoot, { recursive: true, force: true });
|
|
520
|
+
}
|
|
163
521
|
}
|
package/src/runtime/work.mjs
CHANGED
|
@@ -5,6 +5,7 @@ import { validatePrototypeManifest } from '../delivery-artifacts.mjs';
|
|
|
5
5
|
import { projectPaths } from '../paths.mjs';
|
|
6
6
|
import { openRuntimeDatabase } from './database.mjs';
|
|
7
7
|
import { readRuntimeIntent, syncIntentIndex } from './intents.mjs';
|
|
8
|
+
import { withIntentMutation, assertIntentMutation } from './intent-ownership.mjs';
|
|
8
9
|
|
|
9
10
|
const lanes = ['backlog', 'ready', 'active', 'qa', 'blocked', 'done'];
|
|
10
11
|
|
|
@@ -184,13 +185,17 @@ export function syncWorkItems(projectRoot, suppliedDatabase = null) {
|
|
|
184
185
|
title = excluded.title, status = 'active'
|
|
185
186
|
`);
|
|
186
187
|
|
|
188
|
+
// Evidence readers may open their own database connection. Derive before
|
|
189
|
+
// taking the projection writer, avoiding self-contention and false stale
|
|
190
|
+
// results. Canonical mutations revalidate under their intent ownership.
|
|
191
|
+
const derivedItems = intents.map(intent => deriveExecutionState(projectRoot, intentFromRow(intent), {
|
|
192
|
+
intentCatalog: execution.catalog,
|
|
193
|
+
leases: execution.leases.get(intent.id) ?? [],
|
|
194
|
+
}));
|
|
187
195
|
database.exec('BEGIN IMMEDIATE;');
|
|
188
196
|
try {
|
|
189
197
|
intents.forEach((intent, index) => {
|
|
190
|
-
const derived =
|
|
191
|
-
intentCatalog: execution.catalog,
|
|
192
|
-
leases: execution.leases.get(intent.id) ?? [],
|
|
193
|
-
});
|
|
198
|
+
const derived = derivedItems[index];
|
|
194
199
|
const defaults = derived.projection;
|
|
195
200
|
insertItem.run(
|
|
196
201
|
intent.id,
|
|
@@ -394,6 +399,10 @@ export function resolvePrototypeAsset(projectRoot, reference, artefactId, assetP
|
|
|
394
399
|
}
|
|
395
400
|
|
|
396
401
|
export function updateWorkItem(projectRoot, reference, input) {
|
|
402
|
+
return withIntentMutation(projectRoot, reference, { action: 'update-projection', input, ownership: input.ownership }, () => updateOwnedWorkItem(projectRoot, reference, input));
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
function updateOwnedWorkItem(projectRoot, reference, input) {
|
|
397
406
|
return withDatabase(projectRoot, null, (database) => {
|
|
398
407
|
syncWorkItems(projectRoot, database);
|
|
399
408
|
const item = findItem(database, reference);
|
|
@@ -419,6 +428,7 @@ export function updateWorkItem(projectRoot, reference, input) {
|
|
|
419
428
|
if (key === 'completionPercent') return Math.max(0, Math.min(100, Number(value)));
|
|
420
429
|
return value;
|
|
421
430
|
});
|
|
431
|
+
assertIntentMutation(projectRoot, reference);
|
|
422
432
|
database.prepare(`UPDATE work_items SET ${entries.map(([key]) => `${allowed[key]} = ?`).join(', ')}, updated_at = CURRENT_TIMESTAMP WHERE id = ?`)
|
|
423
433
|
.run(...values, item.id);
|
|
424
434
|
}
|
|
@@ -427,6 +437,10 @@ export function updateWorkItem(projectRoot, reference, input) {
|
|
|
427
437
|
}
|
|
428
438
|
|
|
429
439
|
export function setPhase(projectRoot, reference, phaseKey, input = {}) {
|
|
440
|
+
return withIntentMutation(projectRoot, reference, { action: 'update-phase-projection', phase: phaseKey, input, ownership: input.ownership }, () => setOwnedPhase(projectRoot, reference, phaseKey, input));
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
function setOwnedPhase(projectRoot, reference, phaseKey, input) {
|
|
430
444
|
return withDatabase(projectRoot, null, (database) => {
|
|
431
445
|
syncWorkItems(projectRoot, database);
|
|
432
446
|
const item = findItem(database, reference);
|
|
@@ -434,6 +448,7 @@ export function setPhase(projectRoot, reference, phaseKey, input = {}) {
|
|
|
434
448
|
const existing = database.prepare(`
|
|
435
449
|
SELECT status, iteration_count FROM pipeline_phases WHERE work_item_id = ? AND phase_key = ?
|
|
436
450
|
`).get(item.id, phaseKey);
|
|
451
|
+
assertIntentMutation(projectRoot, reference);
|
|
437
452
|
database.prepare(`
|
|
438
453
|
INSERT INTO pipeline_phases (work_item_id, phase_key, status, started_at, completed_at, artefact_path, iteration_count, notes)
|
|
439
454
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
@@ -525,6 +540,7 @@ function recordActivity(projectRoot, reference, input, mode) {
|
|
|
525
540
|
database.exec('BEGIN IMMEDIATE;');
|
|
526
541
|
try {
|
|
527
542
|
const existingSession = database.prepare('SELECT * FROM active_sessions WHERE work_item_id = ?').get(item.id);
|
|
543
|
+
if (mode === 'start') assertIntentMutation(projectRoot, reference, database);
|
|
528
544
|
if (mode === 'start' && existingSession && existingSession.ended_at === null) {
|
|
529
545
|
const requestedOwner = String(input.ownerId ?? '').trim();
|
|
530
546
|
const requestedTool = String(input.tool ?? '').trim();
|
|
@@ -606,7 +622,7 @@ function recordActivity(projectRoot, reference, input, mode) {
|
|
|
606
622
|
}
|
|
607
623
|
|
|
608
624
|
export function startActiveSession(projectRoot, reference, input = {}) {
|
|
609
|
-
return recordActivity(projectRoot, reference, input, 'start');
|
|
625
|
+
return withIntentMutation(projectRoot, reference, { action: 'start-session', input, ownership: input.ownership }, () => recordActivity(projectRoot, reference, input, 'start'));
|
|
610
626
|
}
|
|
611
627
|
|
|
612
628
|
export function addActivityEvent(projectRoot, reference, input = {}) {
|