@ckanner/flywheel 0.33.0 → 0.34.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/CHANGELOG.md +32 -0
- package/README.md +1 -0
- package/README.zh-CN.md +2 -0
- package/THIRD_PARTY_NOTICES.md +5 -1
- package/bin/flywheel-kimi-native-worker.js +5 -0
- package/bin/flywheel.js +8 -1
- package/bin/runtime.js +5 -1
- package/dist/bridge.mjs +89 -7
- package/dist/claude-code-pro-bridge.mjs +55 -6
- package/dist/client.js +107 -10
- package/dist/codex-pro-bridge.mjs +12 -5
- package/dist/control.mjs +1 -1
- package/dist/kimi-native-bridge.mjs +429 -0
- package/dist/kimi-native-runtime.mjs +233 -0
- package/dist/kimi-native-worker.mjs +77 -0
- package/dist/kimi-tower-candidate.mjs +227 -0
- package/dist/machine-worker.mjs +39 -5
- package/dist/managed-lcu.mjs +223 -0
- package/dist/native-doctor.mjs +36 -0
- package/dist/native-jobs.mjs +72 -0
- package/dist/native-policy.mjs +371 -0
- package/dist/native-resources.mjs +144 -0
- package/dist/native.mjs +251 -0
- package/dist/policy.mjs +13 -3
- package/dist/prewarm.mjs +4 -1
- package/dist/protocol.mjs +8 -1
- package/dist/worker.mjs +16 -9
- package/docs/README.md +9 -0
- package/docs/README.zh-CN.md +3 -0
- package/docs/design.md +34 -12
- package/docs/en/claude-code-pro.md +2 -2
- package/docs/en/configuration.md +17 -5
- package/docs/en/contributing.md +5 -3
- package/docs/en/flow-console.md +1 -1
- package/docs/en/getting-started.md +2 -2
- package/docs/en/model-policy.md +2 -2
- package/docs/en/native-capabilities.md +185 -0
- package/docs/en/releasing.md +2 -2
- package/docs/en/session-recovery.md +2 -2
- package/docs/plans/2026-11-13-native-harness-capabilities.en.md +258 -0
- package/docs/plans/2026-11-13-native-harness-capabilities.md +276 -0
- package/docs/plans/README.md +1 -0
- package/docs/validation/2026-11-14-desktop-native-configuration.md +128 -0
- package/docs/validation/2026-11-14-native-harness-implementation.md +215 -0
- package/docs/validation/2026-11-14-published-companions.md +15 -0
- package/docs/validation/2026-11-14-release-0.33.0.md +49 -0
- package/docs/validation/2026-11-14-release-0.33.1.md +78 -0
- package/docs/validation/README.md +4 -0
- package/docs/validation/evidence/2026-11-14-native-harness-candidate.json +255 -0
- package/docs/validation/evidence/2026-11-14-native-harness-sessions.json +6009 -0
- package/docs/validation/evidence/2026-11-14-native-harness-tower-candidate.json +192 -0
- package/docs/zh-CN/claude-code-pro.md +2 -2
- package/docs/zh-CN/configuration.md +21 -8
- package/docs/zh-CN/contributing.md +5 -3
- package/docs/zh-CN/flow-console.md +1 -1
- package/docs/zh-CN/getting-started.md +2 -2
- package/docs/zh-CN/model-policy.md +2 -2
- package/docs/zh-CN/native-capabilities.md +184 -0
- package/docs/zh-CN/releasing.md +2 -2
- package/docs/zh-CN/session-recovery.md +1 -1
- package/native/darwin-amd64/flywheel +0 -0
- package/native/darwin-arm64/flywheel +0 -0
- package/native/linux-amd64/flywheel +0 -0
- package/native/linux-arm64/flywheel +0 -0
- package/native/manifest.json +9 -9
- package/node_modules/dsh-preset-generator/CHANGELOG.md +77 -0
- package/node_modules/dsh-preset-generator/LICENSE +21 -0
- package/node_modules/dsh-preset-generator/README.md +149 -0
- package/node_modules/dsh-preset-generator/docs/README.zh.md +95 -0
- package/node_modules/dsh-preset-generator/package.json +43 -0
- package/node_modules/dsh-preset-generator/src/composition.mjs +150 -0
- package/node_modules/dsh-preset-generator/src/gen-presets.mjs +367 -0
- package/node_modules/dsh-preset-generator/src/policy.mjs +17 -0
- package/node_modules/dsh-subagent-codex-pro/CHANGELOG.md +16 -1
- package/node_modules/dsh-subagent-codex-pro/README.md +57 -4
- package/node_modules/dsh-subagent-codex-pro/assets/native-client.js +118 -0
- package/node_modules/dsh-subagent-codex-pro/cordis.patch.yml +6 -0
- package/node_modules/dsh-subagent-codex-pro/docs/README.zh.md +49 -2
- package/node_modules/dsh-subagent-codex-pro/docs/machine-lifecycle-validation.md +1 -1
- package/node_modules/dsh-subagent-codex-pro/docs/machine-lifecycle.md +1 -1
- package/node_modules/dsh-subagent-codex-pro/docs/machine-lifecycle.zh.md +1 -1
- package/node_modules/dsh-subagent-codex-pro/docs/machine.md +2 -2
- package/node_modules/dsh-subagent-codex-pro/docs/native-capabilities.md +254 -0
- package/node_modules/dsh-subagent-codex-pro/docs/native-capabilities.zh.md +180 -0
- package/node_modules/dsh-subagent-codex-pro/lib/config.js +56 -0
- package/node_modules/dsh-subagent-codex-pro/lib/config.js.map +1 -0
- package/node_modules/dsh-subagent-codex-pro/lib/desktop-native.js +109 -0
- package/node_modules/dsh-subagent-codex-pro/lib/desktop-native.js.map +1 -0
- package/node_modules/dsh-subagent-codex-pro/lib/dsh-types.js +3 -4
- package/node_modules/dsh-subagent-codex-pro/lib/dsh-types.js.map +1 -1
- package/node_modules/dsh-subagent-codex-pro/lib/index.js +64 -15
- package/node_modules/dsh-subagent-codex-pro/lib/index.js.map +1 -1
- package/node_modules/dsh-subagent-codex-pro/lib/jobs.js +6 -2
- package/node_modules/dsh-subagent-codex-pro/lib/jobs.js.map +1 -1
- package/node_modules/dsh-subagent-codex-pro/lib/machine-cli.js +2 -2
- package/node_modules/dsh-subagent-codex-pro/lib/machine-cli.js.map +1 -1
- package/node_modules/dsh-subagent-codex-pro/lib/machine-local.js +5 -2
- package/node_modules/dsh-subagent-codex-pro/lib/machine-local.js.map +1 -1
- package/node_modules/dsh-subagent-codex-pro/lib/machine-supervision.js +11 -0
- package/node_modules/dsh-subagent-codex-pro/lib/machine-supervision.js.map +1 -1
- package/node_modules/dsh-subagent-codex-pro/lib/machine-supervisor.js +1 -0
- package/node_modules/dsh-subagent-codex-pro/lib/machine-supervisor.js.map +1 -1
- package/node_modules/dsh-subagent-codex-pro/lib/machine.js +20 -3
- package/node_modules/dsh-subagent-codex-pro/lib/machine.js.map +1 -1
- package/node_modules/dsh-subagent-codex-pro/lib/native.js +297 -0
- package/node_modules/dsh-subagent-codex-pro/lib/native.js.map +1 -0
- package/node_modules/dsh-subagent-codex-pro/lib/process-guardian.js +11 -1
- package/node_modules/dsh-subagent-codex-pro/lib/process-guardian.js.map +1 -1
- package/node_modules/dsh-subagent-codex-pro/lib/run.js +19 -1
- package/node_modules/dsh-subagent-codex-pro/lib/run.js.map +1 -1
- package/node_modules/dsh-subagent-codex-pro/lib/tool.js +123 -57
- package/node_modules/dsh-subagent-codex-pro/lib/tool.js.map +1 -1
- package/node_modules/dsh-subagent-codex-pro/lib/types/config.d.ts +31 -0
- package/node_modules/dsh-subagent-codex-pro/lib/types/config.d.ts.map +1 -0
- package/node_modules/dsh-subagent-codex-pro/lib/types/desktop-native.d.ts +30 -0
- package/node_modules/dsh-subagent-codex-pro/lib/types/desktop-native.d.ts.map +1 -0
- package/node_modules/dsh-subagent-codex-pro/lib/types/dsh-types.d.ts +7 -5
- package/node_modules/dsh-subagent-codex-pro/lib/types/dsh-types.d.ts.map +1 -1
- package/node_modules/dsh-subagent-codex-pro/lib/types/index.d.ts +31 -0
- package/node_modules/dsh-subagent-codex-pro/lib/types/index.d.ts.map +1 -1
- package/node_modules/dsh-subagent-codex-pro/lib/types/jobs.d.ts +2 -0
- package/node_modules/dsh-subagent-codex-pro/lib/types/jobs.d.ts.map +1 -1
- package/node_modules/dsh-subagent-codex-pro/lib/types/machine-local.d.ts.map +1 -1
- package/node_modules/dsh-subagent-codex-pro/lib/types/machine-supervision.d.ts.map +1 -1
- package/node_modules/dsh-subagent-codex-pro/lib/types/machine.d.ts +7 -0
- package/node_modules/dsh-subagent-codex-pro/lib/types/machine.d.ts.map +1 -1
- package/node_modules/dsh-subagent-codex-pro/lib/types/native.d.ts +120 -0
- package/node_modules/dsh-subagent-codex-pro/lib/types/native.d.ts.map +1 -0
- package/node_modules/dsh-subagent-codex-pro/lib/types/run.d.ts +11 -0
- package/node_modules/dsh-subagent-codex-pro/lib/types/run.d.ts.map +1 -1
- package/node_modules/dsh-subagent-codex-pro/lib/types/tool.d.ts +11 -0
- package/node_modules/dsh-subagent-codex-pro/lib/types/tool.d.ts.map +1 -1
- package/node_modules/dsh-subagent-codex-pro/lib/types/wire.d.ts +7 -1
- package/node_modules/dsh-subagent-codex-pro/lib/types/wire.d.ts.map +1 -1
- package/node_modules/dsh-subagent-codex-pro/lib/wire.js +34 -6
- package/node_modules/dsh-subagent-codex-pro/lib/wire.js.map +1 -1
- package/node_modules/dsh-subagent-codex-pro/package.json +28 -4
- package/npm/install.js +2 -2
- package/package.json +25 -10
- package/plugins/dsh/KIMI_NATIVE_PROTOCOL.en.md +76 -0
- package/plugins/dsh/KIMI_NATIVE_PROTOCOL.md +76 -0
- package/plugins/dsh/PROTOCOL.md +151 -17
- package/plugins/dsh/README.md +15 -12
- package/plugins/dsh/README.zh-CN.md +8 -8
- package/plugins/dsh/THIRD_PARTY_NOTICES.md +5 -1
- package/plugins/dsh/vendor/README.md +40 -11
- package/plugins/dsh/vendor/chatgpt.json +21 -0
- package/plugins/dsh/vendor/claude-code-pro.json +21 -0
- package/plugins/dsh/vendor/codex-pro.json +13 -8
- package/plugins/dsh/vendor/lcu.json +21 -0
- package/plugins/dsh/vendor/preset-generator.json +16 -0
|
@@ -0,0 +1,371 @@
|
|
|
1
|
+
// Runs inside the owned SDK process. All observations use public runtime seams.
|
|
2
|
+
import { readFile, mkdir, open, lstat, realpath } from 'node:fs/promises';
|
|
3
|
+
import { constants } from 'node:fs';
|
|
4
|
+
import { dirname, join } from 'node:path';
|
|
5
|
+
import { createRequire } from 'node:module';
|
|
6
|
+
import { createSdkPtcComposition, validateSdkPtcAdmission, inspectPtcBundle } from 'dsh-preset-generator/composition';
|
|
7
|
+
import { nativeDigest, validateNative } from './native.mjs';
|
|
8
|
+
import { verifyGuiLease } from './native-resources.mjs';
|
|
9
|
+
|
|
10
|
+
export function nativeOverlay(request, runtimeProfile = 'sdk') {
|
|
11
|
+
validateNative(request.native);
|
|
12
|
+
const options = request.native;
|
|
13
|
+
const composition = createSdkPtcComposition({ runtimeProfile, preset: options.preset });
|
|
14
|
+
const enabled = options.agents?.enabled === true;
|
|
15
|
+
const maxConcurrent = options.agents?.maxConcurrent ?? 8;
|
|
16
|
+
const maxDepth = options.agents?.maxDepth ?? 2;
|
|
17
|
+
const overlay = [
|
|
18
|
+
...composition.overlay,
|
|
19
|
+
{ id: 'subagent', config: { maxActiveSubagents: maxConcurrent, maxDepth } },
|
|
20
|
+
...['subagent-spawn-in-process', 'subagent-fork-in-process'].map(id => ({ id, disabled: !enabled })),
|
|
21
|
+
{ id: 'tool-subagent-control', disabled: !enabled, config: { interrupt: true } },
|
|
22
|
+
...[['tool-subagent', 'spawn', 'subagent'], ['tool-subagent-fork', 'fork', 'subagent_fork']].map(([id, provider, toolName]) =>
|
|
23
|
+
({ id, disabled: !enabled, config: { provider, toolName, maxDepth, modelSelection: false } })),
|
|
24
|
+
{ id: 'agent-team', config: { defaultEnabled: options.team?.enabled === true, maxTasks: 256 } },
|
|
25
|
+
{ id: 'tool-agent-team', disabled: options.team?.enabled !== true },
|
|
26
|
+
{ id: 'workflow-ptc', disabled: options.workflows?.enabled !== true,
|
|
27
|
+
config: { provider: 'spawn', maxConcurrentAgents: maxConcurrent, maxTotalAgents: 1000 } },
|
|
28
|
+
{ id: 'tool-workflow', disabled: options.workflows?.enabled !== true },
|
|
29
|
+
{ id: 'tool-web', disabled: !options.web?.search && !options.web?.fetch,
|
|
30
|
+
config: { search: options.web?.search === true, fetch: options.web?.fetch === true } },
|
|
31
|
+
];
|
|
32
|
+
return { composition, overlay };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const PROBE_IMAGE = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAC0lEQVR4nGNgAAIAAAUAAXpeqz8AAAAASUVORK5CYII=', 'base64');
|
|
36
|
+
|
|
37
|
+
/** Execute the actual selected PTC path, without a model request or desktop action. */
|
|
38
|
+
export async function probePtc(ctx, agent, composition, signal = AbortSignal.timeout(10000)) {
|
|
39
|
+
if (!ctx.get('tools') || !ctx.get('systemPrompt') || !ctx.get('ptcRuntime')) throw new Error('Native DSH requires public PTC runtime services');
|
|
40
|
+
const assembly = await ctx.systemPrompt.assemble({ scope: agent });
|
|
41
|
+
const schemas = assembly.tools.map(tool => tool.name);
|
|
42
|
+
if (schemas.length !== 1 || schemas[0] !== 'run_code') throw new Error('Native DSH requires exactly the PTC run_code executor');
|
|
43
|
+
if (ctx.ptcRuntime.language !== 'typescript') throw new Error('Native DSH requires the TypeScript PTC runtime');
|
|
44
|
+
const store = ctx.get('attachments');
|
|
45
|
+
const image = store ? (await store.saveImages([{ data: PROBE_IMAGE, mediaType: 'image/png', name: 'flywheel-ptc-probe.png' }]))[0] : undefined;
|
|
46
|
+
const marker = `flywheel_probe_${nativeDigest(composition).slice(0, 16)}`;
|
|
47
|
+
const disposers = [];
|
|
48
|
+
let result;
|
|
49
|
+
try {
|
|
50
|
+
const definition = (name, images) => ({
|
|
51
|
+
name, description: 'Internal no-effect initialization probe.', parameters: {},
|
|
52
|
+
output: { schema: { type: 'object', required: ['marker', '__handle'], properties: { marker: { type: 'string' },
|
|
53
|
+
__handle: { type: 'string' } }, additionalProperties: false },
|
|
54
|
+
render: () => [{ type: 'text', text: marker }, ...Array.from({ length: image ? images : 0 }, () => ({ type: 'image', attachment: image }))] },
|
|
55
|
+
execute: async () => ({ marker, __handle: 'probe-handle' }),
|
|
56
|
+
});
|
|
57
|
+
disposers.push(agent.ctx.tools.register(definition('flywheel_ptc_probe', 1)));
|
|
58
|
+
disposers.push(agent.ctx.tools.register(definition('flywheel_ptc_multi_probe', 2)));
|
|
59
|
+
// The new executor reserves read_image for conversion into its PtcImage.
|
|
60
|
+
// This temporary agent-scoped tool returns only our known in-memory image;
|
|
61
|
+
// it tests transport identity, not filesystem access or screenshot capture.
|
|
62
|
+
if (image) disposers.push(agent.ctx.tools.register(definition('read_image', 1)));
|
|
63
|
+
const code = `await (async () => {
|
|
64
|
+
const v = await tools.flywheel_ptc_probe({});
|
|
65
|
+
const many = await tools.flywheel_ptc_multi_probe({});
|
|
66
|
+
if (v.__handle !== 'probe-handle' || v.marker !== ${JSON.stringify(marker)} || many.__handle !== 'probe-handle') throw new Error('canonical PTC value lost');
|
|
67
|
+
if (typeof inspect === 'function') {
|
|
68
|
+
if (typeof Image !== 'function') throw new Error('inspect runtime has no Image identity');
|
|
69
|
+
inspect(${JSON.stringify(marker)});
|
|
70
|
+
inspect('flywheel-inspect-v1');
|
|
71
|
+
${image ? "const image = await tools.read_image({}); if (!(image instanceof Image)) throw new Error('PTC Image identity lost'); inspect(image); inspect('flywheel-image-identity-v1');" : ''}
|
|
72
|
+
} else {
|
|
73
|
+
console.log(${JSON.stringify(marker)});
|
|
74
|
+
console.log('flywheel-deferred-v1');
|
|
75
|
+
}
|
|
76
|
+
})();`;
|
|
77
|
+
result = await ctx.tools.execute({ agent, signal, calls: [{ type: 'tool-call', id: marker, name: 'run_code', arguments: JSON.stringify({ code, description: 'Verify PTC canonical values, multi-image projection and selected output transport without model inference' }) }] });
|
|
78
|
+
} finally { for (const dispose of disposers.reverse()) dispose(); }
|
|
79
|
+
const content = result.results?.[0]?.result?.content ?? [];
|
|
80
|
+
const text = content.filter(block => block.type === 'text').map(block => block.text).join('\n');
|
|
81
|
+
if (result.results?.[0]?.result?.isError !== false || !text.includes(marker)) throw new Error('The selected PTC executor failed its canonical-value/output probe');
|
|
82
|
+
const modern = text.includes('flywheel-inspect-v1');
|
|
83
|
+
const legacy = text.includes('flywheel-deferred-v1');
|
|
84
|
+
if (modern === legacy) throw new Error('PTC output transport was not attributable to one runtime contract');
|
|
85
|
+
const sameImage = block => block.type === 'image' && block.attachment?.attachmentId === image?.attachmentId;
|
|
86
|
+
const deferredCount = result.additionalContexts?.flatMap(message => message.content ?? []).filter(sameImage).length ?? 0;
|
|
87
|
+
const inspected = Boolean(image && content.some(sameImage) && text.includes('flywheel-image-identity-v1'));
|
|
88
|
+
const imagesPassed = Boolean(image && deferredCount === 3 && (!modern || inspected));
|
|
89
|
+
return { compositionDigest: composition.digest, runtimeProfile: composition.runtimeProfile, executor: 'ptc', activeExecutors: ['ptc'],
|
|
90
|
+
tools: schemas, outputContract: modern ? 'inspect-image-v1' : 'deferred-context-v1', probeStatus: 'passed',
|
|
91
|
+
imageProbeStatus: image ? (imagesPassed ? 'passed' : 'failed') : 'not-run',
|
|
92
|
+
probes: { canonicalValue: 'passed', multiImageProjection: image ? (deferredCount === 3 ? 'passed' : 'failed') : 'not-run',
|
|
93
|
+
imageIdentity: image && modern ? (inspected ? 'passed' : 'failed') : 'not-run',
|
|
94
|
+
inspectedImageProjection: image && modern ? (inspected ? 'passed' : 'failed') : 'not-run' } };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async function runtimeBundleEvidence() {
|
|
98
|
+
// Resolve from the running SDK entry, not the Flywheel dependency tree.
|
|
99
|
+
// A host with an opaque loader still has behavioral admission; its receipt
|
|
100
|
+
// explicitly omits static bundle evidence rather than inventing a version.
|
|
101
|
+
try {
|
|
102
|
+
const require = createRequire(process.argv[1]);
|
|
103
|
+
const manifestPath = require.resolve('@deepseek-ai/dsh-tool-executor-ptc/package.json');
|
|
104
|
+
const manifest = JSON.parse(await readFile(manifestPath, 'utf8'));
|
|
105
|
+
const entry = require.resolve('@deepseek-ai/dsh-tool-executor-ptc');
|
|
106
|
+
return inspectPtcBundle({ packageJson: manifest, files: { 'entry.js': await readFile(entry, 'utf8') } });
|
|
107
|
+
} catch { return null; }
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Private bounded journal: queue reservations are bounded before I/O begins. */
|
|
111
|
+
export async function createNativeJournal(path, identity) {
|
|
112
|
+
const parent = dirname(path);
|
|
113
|
+
const stat = await lstat(parent);
|
|
114
|
+
if (!stat.isDirectory() || await realpath(parent) !== parent || (stat.mode & 0o022) || (process.getuid && stat.uid !== process.getuid())) {
|
|
115
|
+
throw new Error('Native journal requires an owned physical directory');
|
|
116
|
+
}
|
|
117
|
+
const file = await open(path, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
|
|
118
|
+
let reserved = 0, reservedBytes = 0, written = 0, writtenBytes = 0, error, closed = false, tail = Promise.resolve();
|
|
119
|
+
const fail = cause => { error ??= cause instanceof Error ? cause : new Error(String(cause)); return error; };
|
|
120
|
+
const snapshot = () => ({ complete: !error && reserved === written, events: written, bytes: writtenBytes,
|
|
121
|
+
...(error ? { error: error.message.slice(0, 2048) } : {}) });
|
|
122
|
+
return {
|
|
123
|
+
emit(event) {
|
|
124
|
+
if (error) return Promise.reject(error);
|
|
125
|
+
if (closed) return Promise.reject(fail(new Error('Native journal is already closed')));
|
|
126
|
+
let line;
|
|
127
|
+
try { line = JSON.stringify({ ...event, ...identity, version: 1, journalSequence: reserved + 1 }) + '\n'; }
|
|
128
|
+
catch (cause) { return Promise.reject(fail(cause)); }
|
|
129
|
+
const bytes = Buffer.byteLength(line);
|
|
130
|
+
if (reserved >= 4096 || reservedBytes + bytes > 2 * 1024 * 1024) {
|
|
131
|
+
return Promise.reject(fail(new Error('Native journal exceeded its 4096 event / 2 MiB budget')));
|
|
132
|
+
}
|
|
133
|
+
reserved += 1; reservedBytes += bytes;
|
|
134
|
+
const pending = tail.then(async () => {
|
|
135
|
+
if (error) throw error;
|
|
136
|
+
await file.writeFile(line); await file.sync();
|
|
137
|
+
written += 1; writtenBytes += bytes;
|
|
138
|
+
});
|
|
139
|
+
tail = pending.catch(cause => { fail(cause); });
|
|
140
|
+
return pending;
|
|
141
|
+
},
|
|
142
|
+
async flush() { await tail; if (error) throw error; },
|
|
143
|
+
snapshot,
|
|
144
|
+
async close() {
|
|
145
|
+
if (!closed) { closed = true; await tail; await file.close().catch(fail); }
|
|
146
|
+
return snapshot();
|
|
147
|
+
},
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
async function writePrivateJson(path, value) {
|
|
152
|
+
const file = await open(path, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
|
|
153
|
+
try { await file.writeFile(JSON.stringify(value)); await file.sync(); } finally { await file.close(); }
|
|
154
|
+
const directory = await open(dirname(path), constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW);
|
|
155
|
+
try { await directory.sync(); } finally { await directory.close(); }
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const AGENT_TOOLS = ['subagent', 'subagent_fork', 'list_agents', 'send_message', 'wait_agent', 'interrupt_agent'];
|
|
159
|
+
const TEAM_TOOLS = ['team_task_create', 'team_task_list', 'team_task_get', 'team_task_update'];
|
|
160
|
+
|
|
161
|
+
export async function createNativePolicy(ctx, config) {
|
|
162
|
+
if (!config.native) return null;
|
|
163
|
+
validateNative(config.native);
|
|
164
|
+
const admitted = new Map(), ready = new Set(), lineage = new Map(), activations = new Map();
|
|
165
|
+
const eventsPath = join(dirname(config.receiptPath), 'native-events.jsonl');
|
|
166
|
+
const cleanupPath = join(dirname(config.receiptPath), 'native-cleanup.json');
|
|
167
|
+
const journal = await createNativeJournal(eventsPath, { requestId: config.requestId, nonce: config.nonce,
|
|
168
|
+
rootSessionId: config.sessionId, invocationId: config.managed?.invocationId ?? config.requestId, runId: config.managed?.runId ?? null });
|
|
169
|
+
let lcu, observed, rootAgent, observationFailure;
|
|
170
|
+
const emit = event => journal.emit(event);
|
|
171
|
+
const isLive = agent => agent && String(agent.id) === String(agent.session.id) && ctx.get('agents')?.get(String(agent.id)) === agent;
|
|
172
|
+
const liveLineage = agent => {
|
|
173
|
+
if (!isLive(agent) || admitted.get(String(agent.id)) !== agent || !isLive(rootAgent)) return false;
|
|
174
|
+
const seen = new Set();
|
|
175
|
+
for (let member = agent; member; member = admitted.get(String(member.session.header.parentSession ?? ''))) {
|
|
176
|
+
const record = lineage.get(String(member.id));
|
|
177
|
+
if (seen.has(member) || admitted.get(String(member.id)) !== member || !record ||
|
|
178
|
+
record.parentId !== String(member.session.header.parentSession ?? '') || record.origin !== member.session.header.origin) return false;
|
|
179
|
+
if (member === rootAgent) return String(member.id) === config.sessionId;
|
|
180
|
+
if (member.session.header.origin !== 'subagent') return false;
|
|
181
|
+
seen.add(member);
|
|
182
|
+
}
|
|
183
|
+
return false;
|
|
184
|
+
};
|
|
185
|
+
const identityOf = agent => {
|
|
186
|
+
if (!liveLineage(agent)) throw new Error('LCU caller is outside the live admitted native tree');
|
|
187
|
+
if (agent !== rootAgent && ready.has(String(agent.id)) && ctx.get('subagents')?.rootOf(agent) !== rootAgent) {
|
|
188
|
+
throw new Error('LCU caller has no runtime ownership in the admitted native tree');
|
|
189
|
+
}
|
|
190
|
+
return { runId: config.managed.runId, invocationId: config.managed.invocationId, rootSessionId: config.sessionId,
|
|
191
|
+
...(config.managed.candidateId ? { candidateId: config.managed.candidateId } : {}) };
|
|
192
|
+
};
|
|
193
|
+
// Register cleanup before a host dependency can fail initialization.
|
|
194
|
+
ctx.effect(() => async () => {
|
|
195
|
+
let receipts = [], cleanupError;
|
|
196
|
+
try { receipts = lcu ? await lcu.close('owned SDK shutdown') : []; }
|
|
197
|
+
catch (cause) { cleanupError = cause instanceof Error ? cause.message : String(cause); }
|
|
198
|
+
const facts = await journal.close();
|
|
199
|
+
if (observationFailure) {
|
|
200
|
+
facts.complete = false;
|
|
201
|
+
facts.error ??= (observationFailure instanceof Error ? observationFailure.message : String(observationFailure)).slice(0, 2048);
|
|
202
|
+
}
|
|
203
|
+
await writePrivateJson(cleanupPath, { version: 1, requestId: config.requestId, nonce: config.nonce,
|
|
204
|
+
cleanup: receipts, lcu: lcu?.snapshot() ?? null, journal: facts,
|
|
205
|
+
...(cleanupError ? { error: cleanupError.slice(0, 2048) } : {}) });
|
|
206
|
+
});
|
|
207
|
+
if (config.native.lcu?.enabled) {
|
|
208
|
+
if (!config.managed?.lease) throw new Error('Managed LCU requires an owned desktop lease');
|
|
209
|
+
const { createManagedLcu } = await import('./managed-lcu.mjs');
|
|
210
|
+
lcu = await createManagedLcu(ctx, { options: config.native.lcu.config ?? {}, managed: config.managed,
|
|
211
|
+
rootSessionId: config.sessionId, identityOf, verifyLease: () => verifyGuiLease(config.managed.lease), onEvent: emit });
|
|
212
|
+
}
|
|
213
|
+
const checkTools = async (agent, signal = AbortSignal.timeout(10000)) => {
|
|
214
|
+
const registered = ctx.tools.schemas(agent).map(tool => tool.name).sort();
|
|
215
|
+
const toolSet = await ctx.tools.resolve({ agent, modelSelection: agent.model.current(), signal });
|
|
216
|
+
let capabilities;
|
|
217
|
+
try { capabilities = ctx.tools.schemas(agent, toolSet).map(tool => tool.name).sort(); }
|
|
218
|
+
finally { toolSet.dispose(); }
|
|
219
|
+
const required = [
|
|
220
|
+
...(config.native.agents?.enabled ? AGENT_TOOLS : []), ...(config.native.team?.enabled ? TEAM_TOOLS : []),
|
|
221
|
+
...(config.native.workflows?.enabled ? ['workflow'] : []),
|
|
222
|
+
...(config.native.web?.search ? ['web_search'] : []), ...(config.native.web?.fetch ? ['web_fetch'] : []),
|
|
223
|
+
...(lcu ? ['lcu_cua'] : []),
|
|
224
|
+
];
|
|
225
|
+
const forbidden = [
|
|
226
|
+
...(!config.native.agents?.enabled ? AGENT_TOOLS : []), ...(!config.native.team?.enabled ? TEAM_TOOLS : []),
|
|
227
|
+
...(!config.native.workflows?.enabled ? ['workflow'] : []),
|
|
228
|
+
...(!config.native.web?.search ? ['web_search'] : []), ...(!config.native.web?.fetch ? ['web_fetch'] : []),
|
|
229
|
+
...(lcu ? ['cua', 'js', 'js_reset'] : ['lcu_cua', 'cua']),
|
|
230
|
+
];
|
|
231
|
+
const missing = required.filter(name => !capabilities.includes(name));
|
|
232
|
+
const remaining = forbidden.filter(name => registered.includes(name));
|
|
233
|
+
if (missing.length) throw new Error(`Native capability tools did not register: ${missing.join(', ')}`);
|
|
234
|
+
if (remaining.length) throw new Error(`Unadmitted native capability tools remain registered: ${remaining.join(', ')}`);
|
|
235
|
+
return capabilities;
|
|
236
|
+
};
|
|
237
|
+
const checkRoute = agent => {
|
|
238
|
+
const selection = agent.model.current();
|
|
239
|
+
if (!selection || selection.provider !== config.provider || selection.model !== config.model ||
|
|
240
|
+
(config.reasoningEffort !== undefined && selection.reasoningEffort !== config.reasoningEffort)) {
|
|
241
|
+
throw new Error('Native Agent route or reasoning effort differs from its admitted account/model');
|
|
242
|
+
}
|
|
243
|
+
};
|
|
244
|
+
const checkDepth = agent => {
|
|
245
|
+
if (agent === rootAgent) return;
|
|
246
|
+
const depth = member => Math.max(member.session.header.delegationDepth ?? 0, member.options.subagentDepth ?? 0);
|
|
247
|
+
const parent = admitted.get(String(agent.session.header.parentSession ?? ''));
|
|
248
|
+
const current = depth(agent), parentDepth = parent && depth(parent), rootDepth = rootAgent && depth(rootAgent);
|
|
249
|
+
if (!Number.isSafeInteger(current) || !Number.isSafeInteger(parentDepth) || !Number.isSafeInteger(rootDepth) ||
|
|
250
|
+
current < parentDepth + 1 || current - rootDepth > (config.native.agents?.maxDepth ?? 2)) {
|
|
251
|
+
throw new Error('Native child exceeds its admitted tree delegation depth');
|
|
252
|
+
}
|
|
253
|
+
};
|
|
254
|
+
// These public events include external providers that the SDK JSON-RPC local
|
|
255
|
+
// Session notifications do not expose. Bind each epoch to a real live parent
|
|
256
|
+
// at start, then retain that immutable attribution for its terminal event.
|
|
257
|
+
for (const [name, type] of [['subagent/start', 'subagent.started'], ['subagent/end', 'subagent.finished']]) {
|
|
258
|
+
ctx.on(name, async info => {
|
|
259
|
+
const parent = admitted.get(String(info?.parentSessionId ?? ''));
|
|
260
|
+
if (!parent) return;
|
|
261
|
+
try {
|
|
262
|
+
if (!info || typeof info.runId !== 'string' || !info.runId || typeof info.id !== 'string' || !info.id ||
|
|
263
|
+
typeof info.provider !== 'string' || !info.provider || typeof info.local !== 'boolean') throw new Error('Malformed public native lifecycle identity');
|
|
264
|
+
const child = { runId: info.runId, provider: info.provider, parentSessionId: info.parentSessionId, id: info.id, local: info.local };
|
|
265
|
+
const existing = activations.get(info.runId);
|
|
266
|
+
if (type === 'subagent.started') {
|
|
267
|
+
if (!config.native.agents?.enabled || !ready.has(String(parent.id)) || !liveLineage(parent)) throw new Error('Native lifecycle start has no admitted live parent');
|
|
268
|
+
if (info.local && (admitted.get(info.id) !== ctx.get('agents')?.get(info.id) || !ready.has(info.id))) throw new Error('Local native lifecycle does not name an admitted Agent');
|
|
269
|
+
if (existing && nativeDigest(existing) !== nativeDigest(child)) throw new Error('Native lifecycle epoch changed identity');
|
|
270
|
+
activations.set(info.runId, child);
|
|
271
|
+
} else {
|
|
272
|
+
if (!existing || nativeDigest(existing) !== nativeDigest(child) || typeof info.stopReason !== 'string' || !info.stopReason) throw new Error('Native lifecycle terminal has no matching admitted epoch');
|
|
273
|
+
child.stopReason = info.stopReason;
|
|
274
|
+
}
|
|
275
|
+
await emit({ type, child });
|
|
276
|
+
} catch (cause) {
|
|
277
|
+
// @emit observers are contained by DSH. Persist a failure and make the
|
|
278
|
+
// next awaited step/turn-stop fail instead of trusting a swallowed throw.
|
|
279
|
+
await emit({ type: 'native.observation-failed', matched: false,
|
|
280
|
+
error: (cause instanceof Error ? cause.message : String(cause)).slice(0, 2048) });
|
|
281
|
+
observationFailure ??= cause;
|
|
282
|
+
throw cause;
|
|
283
|
+
}
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
return {
|
|
287
|
+
isChild(agent) {
|
|
288
|
+
const parent = admitted.get(String(agent.session.header.parentSession ?? ''));
|
|
289
|
+
return config.native.agents?.enabled === true && isLive(agent) && agent.session.header.origin === 'subagent' &&
|
|
290
|
+
parent !== undefined && ready.has(String(parent.id)) && isLive(rootAgent) && lineage.has(String(parent.id));
|
|
291
|
+
},
|
|
292
|
+
async admit(agent, root) {
|
|
293
|
+
const id = String(agent.session.id);
|
|
294
|
+
const previous = admitted.get(id), previousLineage = lineage.get(id), previouslyReady = ready.has(id);
|
|
295
|
+
try {
|
|
296
|
+
if (!isLive(agent) || (root ? id !== config.sessionId || rootAgent && rootAgent !== agent : !this.isChild(agent))) throw new Error('Native DSH refuses an unrelated Agent');
|
|
297
|
+
const record = { parentId: String(agent.session.header.parentSession ?? ''), origin: agent.session.header.origin,
|
|
298
|
+
depth: agent.session.header.delegationDepth ?? 0 };
|
|
299
|
+
if (previous && previous !== agent && (root || isLive(previous) || nativeDigest(previousLineage) !== nativeDigest(record))) throw new Error('Native Agent identity or lineage was replaced');
|
|
300
|
+
checkRoute(agent);
|
|
301
|
+
ready.delete(id);
|
|
302
|
+
admitted.set(id, agent);
|
|
303
|
+
lineage.set(id, record);
|
|
304
|
+
if (root) rootAgent = agent;
|
|
305
|
+
checkDepth(agent);
|
|
306
|
+
if (lcu) {
|
|
307
|
+
const info = await ctx.get('llm')?.resolveModelInfo(config.provider, config.model, AbortSignal.timeout(10000));
|
|
308
|
+
if (!info?.inputModalities?.includes('image')) throw new Error('Managed LCU requires an explicitly image-capable model on the selected account');
|
|
309
|
+
await lcu.attach(agent);
|
|
310
|
+
}
|
|
311
|
+
if (root) {
|
|
312
|
+
const team = ctx.get('agentTeams');
|
|
313
|
+
if (team && team.isEnabled(agent) !== Boolean(config.native.team?.enabled)) await team.setEnabled(agent, Boolean(config.native.team?.enabled));
|
|
314
|
+
if (config.native.team?.enabled && !team?.isEnabled?.(agent)) throw new Error('Native DSH Team mode did not become effective');
|
|
315
|
+
const composition = config.composition;
|
|
316
|
+
const probe = await probePtc(ctx, agent, composition);
|
|
317
|
+
const admission = validateSdkPtcAdmission(composition, probe, { requireImages: Boolean(lcu) });
|
|
318
|
+
if (!admission.eligible) throw new Error(`PTC runtime admission failed: ${admission.failures.join(', ')}`);
|
|
319
|
+
const capabilities = await checkTools(agent);
|
|
320
|
+
observed = { ...probe, capabilities, admission, staticEvidence: await runtimeBundleEvidence(), namedPresetMounted: false };
|
|
321
|
+
const binding = { options: config.native, observed, provider: config.provider, model: config.model,
|
|
322
|
+
reasoningEffort: config.reasoningEffort, permissionMode: config.permissionMode, cwd: config.cwd, workspaceCwd: config.workspaceCwd ?? config.cwd };
|
|
323
|
+
observed.runtimeBindingDigest = nativeDigest(binding);
|
|
324
|
+
if (config.managed?.taskDir) {
|
|
325
|
+
const historyDirectory = join(config.managed.taskDir, '.control', 'native-history');
|
|
326
|
+
await mkdir(historyDirectory, { recursive: true, mode: 0o700 });
|
|
327
|
+
for (const directory of [config.managed.taskDir, dirname(historyDirectory), historyDirectory]) {
|
|
328
|
+
const info = await lstat(directory);
|
|
329
|
+
if (!info.isDirectory() || await realpath(directory) !== directory || (info.mode & 0o022) || (process.getuid && info.uid !== process.getuid())) throw new Error('Native history requires owned physical directories');
|
|
330
|
+
}
|
|
331
|
+
const path = join(historyDirectory, nativeDigest(id) + '.json');
|
|
332
|
+
if (config.resuming) {
|
|
333
|
+
const file = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
|
334
|
+
try {
|
|
335
|
+
const info = await file.stat();
|
|
336
|
+
if (!info.isFile() || info.size > 4096 || (info.mode & 0o077) || (process.getuid && info.uid !== process.getuid())) throw new Error('Native history receipt is not a bounded private file');
|
|
337
|
+
const previous = JSON.parse(await file.readFile('utf8'));
|
|
338
|
+
if (previous.version !== 1 || previous.sessionId !== id || previous.digest !== observed.runtimeBindingDigest) throw new Error('Native runtime composition changed; resume withheld before model dispatch');
|
|
339
|
+
} finally { await file.close(); }
|
|
340
|
+
} else await writePrivateJson(path, { version: 1, sessionId: id, digest: observed.runtimeBindingDigest });
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
await emit({ type: 'agent.admitted', id, parentId: agent.session.header.parentSession ?? null, matched: true, permissionMode: config.permissionMode });
|
|
344
|
+
ready.add(id);
|
|
345
|
+
agent.ctx.on('agent/pre-step', async (payload, next) => {
|
|
346
|
+
if (observationFailure) throw observationFailure;
|
|
347
|
+
await journal.flush();
|
|
348
|
+
if (!ready.has(id) || !liveLineage(agent)) throw new Error('Native Agent lineage is no longer live');
|
|
349
|
+
if (agent !== rootAgent && ctx.get('subagents')?.rootOf(agent) !== rootAgent) throw new Error('Native Agent has no runtime ownership in the admitted tree');
|
|
350
|
+
checkRoute(agent); checkDepth(agent); await checkTools(agent, payload.signal);
|
|
351
|
+
const assembly = await ctx.systemPrompt.assemble({ scope: agent });
|
|
352
|
+
if (assembly.tools.length !== 1 || assembly.tools[0].name !== 'run_code') throw new Error('Native Agent lost its admitted PTC executor');
|
|
353
|
+
return next();
|
|
354
|
+
});
|
|
355
|
+
agent.ctx.on('agent/turn-stopping', async () => { await journal.flush(); if (observationFailure) throw observationFailure; });
|
|
356
|
+
return root ? { requested: config.native, observed, matched: true } : undefined;
|
|
357
|
+
} catch (cause) {
|
|
358
|
+
ready.delete(id); admitted.delete(id);
|
|
359
|
+
if (previous && previous !== agent) admitted.set(id, previous);
|
|
360
|
+
if (previouslyReady && previous && previous !== agent) ready.add(id);
|
|
361
|
+
if (previousLineage) lineage.set(id, previousLineage); else lineage.delete(id);
|
|
362
|
+
if (rootAgent === agent) rootAgent = undefined;
|
|
363
|
+
await emit({ type: 'agent.admission-rejected', id, parentId: agent.session.header.parentSession ?? null,
|
|
364
|
+
matched: false, beforeModelDispatch: true,
|
|
365
|
+
parentLive: (() => { const parent = admitted.get(String(agent.session.header.parentSession ?? '')); return Boolean(parent && ready.has(String(parent.id)) && liveLineage(parent)); })(),
|
|
366
|
+
error: (cause instanceof Error ? cause.message : String(cause)).slice(0, 2048) }).catch(() => {});
|
|
367
|
+
throw cause;
|
|
368
|
+
}
|
|
369
|
+
},
|
|
370
|
+
};
|
|
371
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
// A desktop is a physical resource shared by all local SDK workers. A crashed
|
|
2
|
+
// owner leaves a blocking receipt: PID death alone cannot prove GUI cleanup.
|
|
3
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
4
|
+
import { constants } from 'node:fs';
|
|
5
|
+
import { mkdir, open, realpath, rm, lstat, rename } from 'node:fs/promises';
|
|
6
|
+
import { homedir } from 'node:os';
|
|
7
|
+
import { join, isAbsolute, dirname, basename, normalize } from 'node:path';
|
|
8
|
+
import { setTimeout as delay } from 'node:timers/promises';
|
|
9
|
+
import { BridgeError } from './protocol.mjs';
|
|
10
|
+
|
|
11
|
+
const MAX_RECEIPT_BYTES = 16384;
|
|
12
|
+
const valueString = value => typeof value === 'string' && value.length > 0 && value.length <= 4096 && !value.includes('\0');
|
|
13
|
+
const uuid = value => typeof value === 'string' && /^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/.test(value);
|
|
14
|
+
const owned = stat => !process.getuid || stat.uid === process.getuid();
|
|
15
|
+
|
|
16
|
+
async function privateDirectory(path) {
|
|
17
|
+
const info = await lstat(path);
|
|
18
|
+
if (!info.isDirectory() || (info.mode & 0o077) !== 0 || !owned(info) || await realpath(path) !== path) {
|
|
19
|
+
throw new Error('Native resource directory is not an owned private physical directory');
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async function syncDirectory(path) {
|
|
24
|
+
const file = await open(path, constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW);
|
|
25
|
+
try { await file.sync(); } finally { await file.close(); }
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async function receiptLocation(path) {
|
|
29
|
+
if (!valueString(path) || !isAbsolute(path) || normalize(path) !== path || basename(path) !== 'lease.json' || basename(dirname(path)) !== 'local-desktop') {
|
|
30
|
+
throw new Error('GUI lease receipt does not name the canonical resource location');
|
|
31
|
+
}
|
|
32
|
+
await privateDirectory(dirname(dirname(path)));
|
|
33
|
+
await privateDirectory(dirname(path));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async function readLease(path) {
|
|
37
|
+
await receiptLocation(path);
|
|
38
|
+
const file = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
|
39
|
+
try {
|
|
40
|
+
const info = await file.stat();
|
|
41
|
+
if (!info.isFile() || info.size < 1 || info.size > MAX_RECEIPT_BYTES || (info.mode & 0o077) !== 0 || !owned(info)) throw new Error('GUI lease receipt is not a bounded private file');
|
|
42
|
+
const bytes = Buffer.alloc(MAX_RECEIPT_BYTES + 1);
|
|
43
|
+
const { bytesRead } = await file.read(bytes, 0, bytes.length, 0);
|
|
44
|
+
if (bytesRead !== info.size) throw new Error('GUI lease changed during verification');
|
|
45
|
+
const current = JSON.parse(bytes.subarray(0, bytesRead).toString('utf8'));
|
|
46
|
+
if (current.version !== 1 || current.resourceId !== 'local-desktop' || !uuid(current.leaseId) || !uuid(current.leaseEpoch) ||
|
|
47
|
+
!valueString(current.ownerInvocationId) || typeof current.runId !== 'string' || current.runId.length > 4096 ||
|
|
48
|
+
!Number.isSafeInteger(current.pid) || current.pid < 1 || !['held', 'released', 'cleanup-unknown'].includes(current.state) ||
|
|
49
|
+
current.receiptPath !== path || !valueString(current.createdAt) || !Number.isFinite(Date.parse(current.createdAt))) {
|
|
50
|
+
throw new Error('GUI lease receipt has an invalid identity or lifecycle');
|
|
51
|
+
}
|
|
52
|
+
return current;
|
|
53
|
+
} finally { await file.close(); }
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export async function verifyGuiLease(lease) {
|
|
57
|
+
if (!lease || lease.resourceId !== 'local-desktop' || !uuid(lease.leaseId) || !uuid(lease.leaseEpoch) || !valueString(lease.ownerInvocationId)) {
|
|
58
|
+
throw new Error('Missing managed desktop grant');
|
|
59
|
+
}
|
|
60
|
+
const current = await readLease(lease.receiptPath);
|
|
61
|
+
if (current.state !== 'held' || ['leaseId', 'leaseEpoch', 'ownerInvocationId', 'resourceId'].some(key => current[key] !== lease[key])) {
|
|
62
|
+
throw new Error('Desktop grant expired or belongs to another invocation');
|
|
63
|
+
}
|
|
64
|
+
return current;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export async function acquireGuiLease(managed, { env = process.env, signal, onWait = () => {}, alive = pid => {
|
|
68
|
+
try { process.kill(pid, 0); return true; } catch (error) { return error.code !== 'ESRCH'; }
|
|
69
|
+
} } = {}) {
|
|
70
|
+
if (!managed || !valueString(managed.invocationId) || typeof managed.runId !== 'string' || managed.runId.length > 4096) {
|
|
71
|
+
throw new BridgeError('INVALID_REQUEST', 'LCU requires a managed invocation identity');
|
|
72
|
+
}
|
|
73
|
+
const root = env.FLYWHEEL_RESOURCE_DIR || join(homedir(), '.flywheel', 'resources');
|
|
74
|
+
if (!isAbsolute(root)) throw new BridgeError('INVALID_REQUEST', 'FLYWHEEL_RESOURCE_DIR must be absolute');
|
|
75
|
+
await mkdir(root, { recursive: true, mode: 0o700 });
|
|
76
|
+
const physical = await realpath(root);
|
|
77
|
+
await privateDirectory(physical);
|
|
78
|
+
const directory = join(physical, 'local-desktop');
|
|
79
|
+
const receiptPath = join(directory, 'lease.json');
|
|
80
|
+
let waitingPublished = false, missingSince;
|
|
81
|
+
for (;;) {
|
|
82
|
+
signal?.throwIfAborted();
|
|
83
|
+
let created = false;
|
|
84
|
+
try { await mkdir(directory, { mode: 0o700 }); created = true; }
|
|
85
|
+
catch (error) { if (error.code !== 'EEXIST') throw error; }
|
|
86
|
+
if (created) {
|
|
87
|
+
// Any failure after mkdir leaves an incomplete, blocking owner. Never
|
|
88
|
+
// erase it speculatively: this is the same fail-closed crash boundary.
|
|
89
|
+
const receipt = { version: 1, leaseId: randomUUID(), leaseEpoch: randomUUID(), resourceId: 'local-desktop',
|
|
90
|
+
ownerInvocationId: managed.invocationId, runId: managed.runId, pid: process.pid, state: 'held', createdAt: new Date().toISOString(), receiptPath };
|
|
91
|
+
const body = JSON.stringify(receipt);
|
|
92
|
+
if (Buffer.byteLength(body) > MAX_RECEIPT_BYTES) throw new Error('Managed desktop receipt exceeded its size limit');
|
|
93
|
+
const file = await open(receiptPath, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
|
|
94
|
+
try { await file.writeFile(body); await file.sync(); } finally { await file.close(); }
|
|
95
|
+
await syncDirectory(directory); await syncDirectory(physical);
|
|
96
|
+
// Ownership has committed. Return it even if cancellation raced the
|
|
97
|
+
// fsync, so the caller can prove no dispatch and settle its own receipt.
|
|
98
|
+
return receipt;
|
|
99
|
+
}
|
|
100
|
+
let current;
|
|
101
|
+
try { current = await readLease(receiptPath); missingSince = undefined; }
|
|
102
|
+
catch (error) {
|
|
103
|
+
if (error.code !== 'ENOENT') throw error;
|
|
104
|
+
// A confirmed owner may have removed the directory between our mkdir
|
|
105
|
+
// attempt and read. Retry acquisition; an existing empty directory is a
|
|
106
|
+
// distinct incomplete-owner state and cannot be stolen after a crash.
|
|
107
|
+
try { await lstat(directory); }
|
|
108
|
+
catch (stateError) { if (stateError.code === 'ENOENT') { missingSince = undefined; continue; } throw stateError; }
|
|
109
|
+
missingSince ??= Date.now();
|
|
110
|
+
if (Date.now() - missingSince > 1000) throw new Error(`Desktop ownership is incomplete; reconcile ${directory} before another GUI run`);
|
|
111
|
+
await delay(50, undefined, { signal });
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
if (current.state !== 'held' || !alive(current.pid)) {
|
|
115
|
+
throw new Error(`Desktop cleanup is unconfirmed for invocation ${current.ownerInvocationId}; reconcile lease ${current.leaseId} before another GUI run`);
|
|
116
|
+
}
|
|
117
|
+
if (!waitingPublished) { await onWait({ resourceId: 'local-desktop', ownerInvocationId: current.ownerInvocationId, leaseId: current.leaseId }); waitingPublished = true; }
|
|
118
|
+
await delay(100, undefined, { signal });
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export async function settleGuiLease(lease, confirmed, evidence = null) {
|
|
123
|
+
if (typeof confirmed !== 'boolean') throw new Error('Desktop cleanup confirmation must be explicit');
|
|
124
|
+
const current = await verifyGuiLease(lease);
|
|
125
|
+
// Full receipts live in the invocation ledger. Keep a bounded integrity link
|
|
126
|
+
// here so a large media/child receipt cannot make a blocker unreadable.
|
|
127
|
+
const body = JSON.stringify(evidence ?? null);
|
|
128
|
+
const evidenceRef = { sha256: createHash('sha256').update(body).digest('hex'), bytes: Buffer.byteLength(body) };
|
|
129
|
+
const updated = { ...current, state: confirmed ? 'released' : 'cleanup-unknown', finishedAt: new Date().toISOString(), evidence: evidenceRef };
|
|
130
|
+
const pending = lease.receiptPath + '.terminal';
|
|
131
|
+
const file = await open(pending, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
|
|
132
|
+
try { await file.writeFile(JSON.stringify(updated)); await file.sync(); } finally { await file.close(); }
|
|
133
|
+
await syncDirectory(dirname(lease.receiptPath));
|
|
134
|
+
// Only this invocation may retire the lease. A timeout, PID death or another
|
|
135
|
+
// process never permits stealing it. Unknown cleanup remains a durable block.
|
|
136
|
+
if (confirmed) {
|
|
137
|
+
await rm(dirname(lease.receiptPath), { recursive: true });
|
|
138
|
+
await syncDirectory(dirname(dirname(lease.receiptPath)));
|
|
139
|
+
} else {
|
|
140
|
+
await rename(pending, lease.receiptPath);
|
|
141
|
+
await syncDirectory(dirname(lease.receiptPath));
|
|
142
|
+
}
|
|
143
|
+
return updated;
|
|
144
|
+
}
|