@kingsoftcloud/agentkit-harness-node 0.0.0-stage → 0.0.2
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/LICENSE +201 -0
- package/NOTICE +1 -0
- package/README.md +124 -2
- package/THIRD_PARTY_NOTICES.md +66 -0
- package/ZCode-LICENSE +202 -0
- package/dist/index.mjs +4871 -0
- package/dist/native-command-plan.LICENSE.txt +221 -0
- package/dist/native-command-plan.json +16 -0
- package/dist/native-command-plan.mjs +8 -0
- package/dist/rpc-entry.mjs +5682 -0
- package/package.json +169 -4
- package/src/native/a2a-receipt-source.mjs +19 -0
- package/src/native/a2a-tools.mjs +517 -0
- package/src/native/activity-status.mjs +30 -0
- package/src/native/artifact-codec.mjs +481 -0
- package/src/native/artifact-store.mjs +1162 -0
- package/src/native/assistant-stream.mjs +76 -0
- package/src/native/background-command-port.mjs +95 -0
- package/src/native/browser-action-observation.mjs +43 -0
- package/src/native/browser-availability.mjs +22 -0
- package/src/native/browser-canvas-metrics.mjs +71 -0
- package/src/native/browser-dom.mjs +193 -0
- package/src/native/browser-playwright.mjs +1249 -0
- package/src/native/browser-vision.mjs +369 -0
- package/src/native/browser-workspace-policy.mjs +22 -0
- package/src/native/browser-workspace-protocol.mjs +95 -0
- package/src/native/browser-workspace-server.mjs +164 -0
- package/src/native/browser-workspace.mjs +355 -0
- package/src/native/captured-source.mjs +3 -0
- package/src/native/child-budget-allocation.mjs +87 -0
- package/src/native/child-capabilities.mjs +35 -0
- package/src/native/child-command-resource.mjs +20 -0
- package/src/native/child-history.mjs +188 -0
- package/src/native/child-merge.mjs +98 -0
- package/src/native/child-model-surface.mjs +19 -0
- package/src/native/child-observation.mjs +100 -0
- package/src/native/child-owner.mjs +620 -0
- package/src/native/child-publication.mjs +76 -0
- package/src/native/child-readers.mjs +366 -0
- package/src/native/child-tools.mjs +46 -0
- package/src/native/child-workspace-binding.mjs +29 -0
- package/src/native/child-workspace-tools.mjs +1 -0
- package/src/native/child-workspaces.mjs +655 -0
- package/src/native/children.mjs +365 -0
- package/src/native/command-plan-worker.mjs +14 -0
- package/src/native/command-plan.mjs +31 -0
- package/src/native/command-progress.mjs +64 -0
- package/src/native/command-shell.mjs +9 -0
- package/src/native/command-workspace-observation.mjs +36 -0
- package/src/native/component-tools.mjs +280 -0
- package/src/native/context-counter.mjs +133 -0
- package/src/native/context-markers.mjs +51 -0
- package/src/native/context-profile.mjs +72 -0
- package/src/native/context-status.mjs +27 -0
- package/src/native/conversation-command-control.mjs +133 -0
- package/src/native/delivery-timeline.mjs +39 -0
- package/src/native/dsh-background-ownership.mjs +102 -0
- package/src/native/dsh-managed-ownership.mjs +23 -0
- package/src/native/dsh-turn-contract.mjs +3 -0
- package/src/native/dsh-turn-ownership.mjs +54 -0
- package/src/native/dsh-turn-runtime.mjs +498 -0
- package/src/native/e2b-background-supervisor.mjs +347 -0
- package/src/native/e2b-background.mjs +368 -0
- package/src/native/e2b-command-state.mjs +40 -0
- package/src/native/e2b-command-tools.mjs +486 -0
- package/src/native/e2b-sandbox-owner.mjs +227 -0
- package/src/native/hook-executor.mjs +249 -0
- package/src/native/instruction-assembly.mjs +60 -0
- package/src/native/instruction-context.mjs +54 -0
- package/src/native/local-background-supervisor.mjs +108 -0
- package/src/native/local-background.mjs +207 -0
- package/src/native/local-command-evidence.mjs +57 -0
- package/src/native/local-command-policy-worker.mjs +197 -0
- package/src/native/local-command-policy.mjs +46 -0
- package/src/native/local-command-state.mjs +177 -0
- package/src/native/local-command-tools.mjs +302 -0
- package/src/native/local-execution-profile.mjs +36 -0
- package/src/native/local-output-policy.mjs +92 -0
- package/src/native/local-process-bin/darwin-arm64/launcher +0 -0
- package/src/native/local-process-bin/darwin-x64/launcher +0 -0
- package/src/native/local-process-bin/linux-arm64/launcher +0 -0
- package/src/native/local-process-bin/linux-x64/launcher +0 -0
- package/src/native/local-process-launcher.c +183 -0
- package/src/native/local-process-licenses/GCC-COPYING.RUNTIME +72 -0
- package/src/native/local-process-licenses/GCC-COPYING3 +674 -0
- package/src/native/local-process-licenses/NOTICE +21 -0
- package/src/native/local-process-licenses/musl-COPYRIGHT +193 -0
- package/src/native/local-process-manifest.json +32 -0
- package/src/native/local-process.mjs +246 -0
- package/src/native/local-resource-profile.mjs +32 -0
- package/src/native/memory-candidate.mjs +121 -0
- package/src/native/memory-extraction-run.mjs +60 -0
- package/src/native/memory-extraction-tools.mjs +116 -0
- package/src/native/memory-recall.mjs +65 -0
- package/src/native/memory-store.mjs +334 -0
- package/src/native/memory-tools.mjs +329 -0
- package/src/native/model-anthropic-cache.mjs +49 -0
- package/src/native/model-anthropic-tool-search.mjs +85 -0
- package/src/native/model-anthropic.mjs +753 -0
- package/src/native/model-chat-usage.mjs +24 -0
- package/src/native/model-inline-reasoning.mjs +111 -0
- package/src/native/model-media.mjs +174 -0
- package/src/native/model-normalize.mjs +228 -0
- package/src/native/model-openai-chat.mjs +561 -0
- package/src/native/model-port.mjs +361 -0
- package/src/native/model-provider-compat.mjs +129 -0
- package/src/native/model-replay.mjs +153 -0
- package/src/native/model-request.mjs +15 -0
- package/src/native/model-responses.mjs +1205 -0
- package/src/native/model-sse.mjs +171 -0
- package/src/native/model-surface-host.mjs +27 -0
- package/src/native/model-tool-search-policy.mjs +16 -0
- package/src/native/model-transport-observation.mjs +27 -0
- package/src/native/owned-child.mjs +114 -0
- package/src/native/owned-command.mjs +106 -0
- package/src/native/python-syntax-worker.mjs +60 -0
- package/src/native/python-syntax.mjs +50 -0
- package/src/native/read-interruption.mjs +16 -0
- package/src/native/result-stream.mjs +63 -0
- package/src/native/ruff-ast.mjs +95 -0
- package/src/native/run-observation.mjs +128 -0
- package/src/native/session-delivery-journal.mjs +96 -0
- package/src/native/session-delivery.mjs +434 -0
- package/src/native/shell-analysis.mjs +50 -0
- package/src/native/skill-disclosure.mjs +404 -0
- package/src/native/skill-events.mjs +164 -0
- package/src/native/skill-tools.mjs +813 -0
- package/src/native/source-page.mjs +73 -0
- package/src/native/sqlite-session-inbox.mjs +42 -0
- package/src/native/sqlite-store.mjs +617 -0
- package/src/native/tool-authority.mjs +99 -0
- package/src/native/tool-guidance.mjs +12 -0
- package/src/native/tool-observation.mjs +75 -0
- package/src/native/tool-states.mjs +14 -0
- package/src/native/verification-authority.mjs +37 -0
- package/src/native/web-tools.mjs +643 -0
- package/src/native/workspace-checkpoints.mjs +312 -0
- package/src/native/workspace-context.mjs +61 -0
- package/src/native/workspace-display.mjs +39 -0
- package/src/native/workspace-edit-match.mjs +19 -0
- package/src/native/workspace-edit-worker.mjs +15 -0
- package/src/native/workspace-editing.mjs +447 -0
- package/src/native/workspace-image.mjs +72 -0
- package/src/native/workspace-instructions.mjs +107 -0
- package/src/native/workspace-mutation-lock.mjs +74 -0
- package/src/native/workspace-patch-worker.mjs +17 -0
- package/src/native/workspace-patch.mjs +17 -0
- package/src/native/workspace-paths.mjs +225 -0
- package/src/native/workspace-state.mjs +272 -0
- package/src/native/workspace-subject-observer.mjs +84 -0
- package/src/native/workspace-tools.mjs +1740 -0
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
// Host-private persistence for local background commands. No workspace imports.
|
|
2
|
+
import { DatabaseSync } from 'node:sqlite';
|
|
3
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
4
|
+
import { constants, closeSync, existsSync, fstatSync, fsyncSync, lstatSync, mkdirSync,
|
|
5
|
+
openSync, readFileSync, readSync, renameSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
6
|
+
import { dirname, join, resolve } from 'node:path';
|
|
7
|
+
import { validCommandEvidence } from './local-command-evidence.mjs';
|
|
8
|
+
|
|
9
|
+
export const LOCAL_BACKGROUND_CONTRACT = 'agentkit.local-background-command/v1';
|
|
10
|
+
export const START_GRACE_MS = 5000;
|
|
11
|
+
export const TERMINAL = new Set(['completed', 'failed', 'canceled', 'timed_out', 'output_limit', 'unknown']);
|
|
12
|
+
export class LocalCommandError extends Error { constructor(code) { super(code); this.name = 'LocalCommandError'; this.code = code; } }
|
|
13
|
+
export const check = (condition, code) => { if (!condition) throw new LocalCommandError(code); };
|
|
14
|
+
export const validId = value => typeof value === 'string' && /^[a-f0-9]{64}$/.test(value);
|
|
15
|
+
export const delay = ms => new Promise(resolve => setTimeout(resolve, ms));
|
|
16
|
+
export const digest = value => createHash('sha256').update(JSON.stringify(value, (_key, item) =>
|
|
17
|
+
item && typeof item === 'object' && !Array.isArray(item)
|
|
18
|
+
? Object.fromEntries(Object.keys(item).sort().map(key => [key, item[key]])) : item)).digest('hex');
|
|
19
|
+
|
|
20
|
+
function noSymlinks(path) {
|
|
21
|
+
for (let part = resolve(path);; part = dirname(part)) {
|
|
22
|
+
try { check(!lstatSync(part).isSymbolicLink(), 'background_state_symlink'); }
|
|
23
|
+
catch (error) { if (error.code !== 'ENOENT') throw error; }
|
|
24
|
+
if (dirname(part) === part) break;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
export function privateDirectory(path, create = false) {
|
|
28
|
+
noSymlinks(path);
|
|
29
|
+
if (create) {
|
|
30
|
+
const missing = [];
|
|
31
|
+
for (let part = resolve(path); !existsSync(part); part = dirname(part)) missing.push(part);
|
|
32
|
+
mkdirSync(path, { recursive: true, mode: 0o700 });
|
|
33
|
+
for (const part of missing.reverse()) { syncDirectory(part); syncDirectory(dirname(part)); }
|
|
34
|
+
}
|
|
35
|
+
const stat = lstatSync(path);
|
|
36
|
+
check(stat.isDirectory() && stat.uid === process.getuid() && (stat.mode & 0o077) === 0, 'background_state_not_private');
|
|
37
|
+
}
|
|
38
|
+
export function privateFile(path, allowMissing = false) {
|
|
39
|
+
try {
|
|
40
|
+
const stat = lstatSync(path);
|
|
41
|
+
check(stat.isFile() && stat.nlink === 1 && stat.uid === process.getuid() && (stat.mode & 0o077) === 0,
|
|
42
|
+
'background_state_not_private');
|
|
43
|
+
return stat;
|
|
44
|
+
} catch (error) { if (allowMissing && error.code === 'ENOENT') return null; throw error; }
|
|
45
|
+
}
|
|
46
|
+
export function openPrivate(path, flags) {
|
|
47
|
+
for (let attempt = 0; ; attempt++) {
|
|
48
|
+
privateDirectory(dirname(path)); privateFile(path, true);
|
|
49
|
+
const fd = openSync(path, flags | constants.O_NOFOLLOW | constants.O_NONBLOCK, 0o600);
|
|
50
|
+
try {
|
|
51
|
+
const stat = fstatSync(fd), current = privateFile(path);
|
|
52
|
+
check(stat.isFile() && stat.dev === current.dev && stat.ino === current.ino, 'background_state_changed');
|
|
53
|
+
return fd;
|
|
54
|
+
} catch (error) {
|
|
55
|
+
closeSync(fd);
|
|
56
|
+
// The supervisor atomically replaces outputs and records. A read opened
|
|
57
|
+
// across that publication must retry the newly verified private inode.
|
|
58
|
+
// Never retry writes, unsafe permissions/links, or persistent churn.
|
|
59
|
+
if (flags !== constants.O_RDONLY || error.code !== 'background_state_changed' || attempt >= 3) throw error;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
export function syncDirectory(directory) {
|
|
64
|
+
const fd = openSync(directory, constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW);
|
|
65
|
+
try { fsyncSync(fd); } finally { closeSync(fd); }
|
|
66
|
+
}
|
|
67
|
+
// Fsync an existing private file's complete contents in place, without reading
|
|
68
|
+
// it into memory. Used by the supervisor to durably persist streamed output files
|
|
69
|
+
// before publishing the terminal receipt, so a crash never loses full output
|
|
70
|
+
// that the preview-only receipt cannot reconstruct. Never overwrites the file.
|
|
71
|
+
export function fsyncPrivate(path) {
|
|
72
|
+
const fd = openPrivate(path, constants.O_WRONLY);
|
|
73
|
+
try { fsyncSync(fd); } finally { closeSync(fd); }
|
|
74
|
+
}
|
|
75
|
+
export function atomicPrivate(path, bytes) {
|
|
76
|
+
privateDirectory(dirname(path)); privateFile(path, true);
|
|
77
|
+
const temporary = join(dirname(path), `.${randomUUID()}.tmp`);
|
|
78
|
+
try {
|
|
79
|
+
const fd = openPrivate(temporary, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL);
|
|
80
|
+
try { writeFileSync(fd, bytes); fsyncSync(fd); } finally { closeSync(fd); }
|
|
81
|
+
privateFile(path, true); renameSync(temporary, path); syncDirectory(dirname(path));
|
|
82
|
+
} finally { if (existsSync(temporary)) unlinkSync(temporary); }
|
|
83
|
+
}
|
|
84
|
+
export function createPrivate(path) {
|
|
85
|
+
const fd = openPrivate(path, constants.O_RDWR | constants.O_CREAT | constants.O_EXCL);
|
|
86
|
+
try { fsyncSync(fd); } finally { closeSync(fd); }
|
|
87
|
+
syncDirectory(dirname(path));
|
|
88
|
+
}
|
|
89
|
+
export function readRecord(directory, owner, commandId) {
|
|
90
|
+
privateDirectory(directory);
|
|
91
|
+
const fd = openPrivate(join(directory, 'record.json'), constants.O_RDONLY);
|
|
92
|
+
let record;
|
|
93
|
+
try {
|
|
94
|
+
check(fstatSync(fd).size <= 65536, 'invalid_background_record');
|
|
95
|
+
try { record = JSON.parse(readFileSync(fd, 'utf8')); }
|
|
96
|
+
catch { throw new LocalCommandError('invalid_background_record'); }
|
|
97
|
+
} finally { closeSync(fd); }
|
|
98
|
+
check(record && record.schemaVersion === LOCAL_BACKGROUND_CONTRACT && record.owner === owner && record.commandId === commandId
|
|
99
|
+
&& validId(owner) && validId(commandId) && validId(record.requestDigest)
|
|
100
|
+
&& ['starting', 'running', ...TERMINAL].includes(record.status)
|
|
101
|
+
&& Number.isSafeInteger(record.createdAt) && record.createdAt > 0
|
|
102
|
+
&& Number.isSafeInteger(record.timeoutMs) && record.timeoutMs > 0
|
|
103
|
+
&& (record.exitCode === null || Number.isSafeInteger(record.exitCode)), 'invalid_background_record');
|
|
104
|
+
check((!['starting', 'running', 'unknown'].includes(record.status) || record.exitCode === null)
|
|
105
|
+
&& (record.status !== 'completed' || record.exitCode === 0)
|
|
106
|
+
&& (record.status !== 'failed' || Number.isSafeInteger(record.exitCode) && record.exitCode !== 0)
|
|
107
|
+
&& (record.status !== 'timed_out' || record.exitCode === 124)
|
|
108
|
+
&& (record.status !== 'output_limit' || record.exitCode === 122)
|
|
109
|
+
&& (record.supervisorPid === undefined || Number.isSafeInteger(record.supervisorPid) && record.supervisorPid > 0)
|
|
110
|
+
&& (record.evidenceIncomplete === undefined || typeof record.evidenceIncomplete === 'boolean')
|
|
111
|
+
&& (record.observedExitCode === undefined || record.observedExitCode === null || Number.isSafeInteger(record.observedExitCode)),
|
|
112
|
+
'invalid_background_record');
|
|
113
|
+
check(record.commandEvidence === undefined || validCommandEvidence(record.commandEvidence), 'invalid_background_record');
|
|
114
|
+
return record;
|
|
115
|
+
}
|
|
116
|
+
export function writeRecord(directory, record) {
|
|
117
|
+
atomicPrivate(join(directory, 'record.json'), JSON.stringify(record) + '\n');
|
|
118
|
+
}
|
|
119
|
+
export function unknownRecord(directory, record, outcomeReason) {
|
|
120
|
+
const next = { ...record, status: 'unknown', exitCode: null, completedAt: Date.now(), evidenceIncomplete: true, outcomeReason };
|
|
121
|
+
writeRecord(directory, next); return next;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Each command has a separate SQLite lock database. The supervisor holds its
|
|
125
|
+
// write transaction for its entire lifetime; command records/output remain
|
|
126
|
+
// independently readable. A crash releases the kernel lock, with no PID test.
|
|
127
|
+
// node:sqlite has no O_NOFOLLOW option. Private ancestors and inode checks
|
|
128
|
+
// reject persistent substitutions; this is not isolation from a malicious uid.
|
|
129
|
+
export function tryLock(path, busyTimeoutMs = 0) {
|
|
130
|
+
check(Number.isSafeInteger(busyTimeoutMs) && busyTimeoutMs >= 0 && busyTimeoutMs <= 5000, 'invalid_lock_timeout');
|
|
131
|
+
privateDirectory(dirname(path));
|
|
132
|
+
if (!privateFile(path, true)) {
|
|
133
|
+
try { createPrivate(path); } catch (error) { if (error.code !== 'EEXIST') throw error; }
|
|
134
|
+
}
|
|
135
|
+
const before = privateFile(path);
|
|
136
|
+
for (const suffix of ['-journal', '-wal', '-shm']) privateFile(path + suffix, true);
|
|
137
|
+
let db;
|
|
138
|
+
try {
|
|
139
|
+
db = new DatabaseSync(path);
|
|
140
|
+
const after = privateFile(path);
|
|
141
|
+
check(before.dev === after.dev && before.ino === after.ino, 'background_state_changed');
|
|
142
|
+
db.exec(`PRAGMA busy_timeout=${busyTimeoutMs}; PRAGMA synchronous=FULL; BEGIN IMMEDIATE;`);
|
|
143
|
+
return () => { try { db.exec('ROLLBACK'); } finally { db.close(); } };
|
|
144
|
+
} catch (error) {
|
|
145
|
+
db?.close();
|
|
146
|
+
if (error.errcode === 5 || error.errcode === 6) return null;
|
|
147
|
+
throw error;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
export async function lockUntil(path, deadline) {
|
|
151
|
+
for (;;) {
|
|
152
|
+
const release = tryLock(path);
|
|
153
|
+
if (release) return release;
|
|
154
|
+
check(performance.now() < deadline, 'background_command_lock_timeout');
|
|
155
|
+
await delay(20);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
export function readWindow(path, cursor, maxBytes) {
|
|
159
|
+
check(Number.isSafeInteger(cursor) && cursor >= 0, 'invalid_background_output_cursor');
|
|
160
|
+
const fd = openPrivate(path, constants.O_RDONLY);
|
|
161
|
+
try {
|
|
162
|
+
const size = fstatSync(fd).size;
|
|
163
|
+
check(cursor <= size, 'background_output_cursor_past_end');
|
|
164
|
+
const data = Buffer.alloc(Math.min(maxBytes, size - cursor));
|
|
165
|
+
const length = readSync(fd, data, 0, data.length, cursor);
|
|
166
|
+
let text;
|
|
167
|
+
try { text = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(data.subarray(0, length), { stream: true }); }
|
|
168
|
+
catch { throw new LocalCommandError('background_output_cursor_not_utf8_boundary'); }
|
|
169
|
+
const next = cursor + Buffer.byteLength(text);
|
|
170
|
+
return { text, cursor: next, hasMore: next < size };
|
|
171
|
+
} finally { closeSync(fd); }
|
|
172
|
+
}
|
|
173
|
+
export function readAll(path) {
|
|
174
|
+
const fd = openPrivate(path, constants.O_RDONLY);
|
|
175
|
+
try { return new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(readFileSync(fd)); }
|
|
176
|
+
finally { closeSync(fd); }
|
|
177
|
+
}
|
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
import { lstatSync, mkdirSync, realpathSync } from 'node:fs';
|
|
2
|
+
import { isAbsolute, join, relative, resolve, sep } from 'node:path';
|
|
3
|
+
import { canonical, copy, invariant, toolInputRejection } from '@kingsoftcloud/agentkit-harness-core';
|
|
4
|
+
import { constants as osConstants } from 'node:os';
|
|
5
|
+
import { LocalCommandStore, createLocalBackgroundReader } from './local-background.mjs';
|
|
6
|
+
import { TERMINAL, delay, readRecord } from './local-command-state.mjs';
|
|
7
|
+
import { buildScriptEnvironment, localControlSettings, runBoundedProcess, LOCAL_PROCESS_CONTRACT } from './local-process.mjs';
|
|
8
|
+
import { localEnvironmentPolicy, localOutputPolicy, resolveOutputPolicy } from './local-output-policy.mjs';
|
|
9
|
+
import { checkGovernedCommand, LOCAL_COMMAND_POLICY, GOVERNED_COMMAND_POLICY } from './local-command-policy.mjs';
|
|
10
|
+
import { checkPlanCommand, PLAN_COMMAND_POLICY } from './command-plan.mjs';
|
|
11
|
+
import { commandFailureEvidence, commandExceptionCause } from './local-command-evidence.mjs';
|
|
12
|
+
import { captureCommandWorkspace, commandWorkspaceChange } from './command-workspace-observation.mjs';
|
|
13
|
+
import { LOCAL_COMMAND_SHELL, shellCommandArgv } from './command-shell.mjs';
|
|
14
|
+
|
|
15
|
+
export const LOCAL_COMMAND_TOOLS_CONTRACT = 'agentkit.local-command-tools/v1';
|
|
16
|
+
export const LOCAL_COMMAND_PROGRESS_CONTRACT = 'agentkit.local-command-progress/v1';
|
|
17
|
+
const schemas = { nullableSeconds: { anyOf: [{ type: 'integer', minimum: 1 }, { type: 'null' }] },
|
|
18
|
+
cursor: { type: 'integer', minimum: 0, maximum: Number.MAX_SAFE_INTEGER } };
|
|
19
|
+
const base = (name, properties, required, mutating, timeoutMs) => ({ name,
|
|
20
|
+
description: name === 'run_command' ? 'Run a governed Bash command inside the bound local workspace. background=true returns an owned task_id; read_command_output observes it and stop_command requests cancellation. This shares the Host filesystem and network; it is not an isolated OS sandbox.'
|
|
21
|
+
: name === 'read_command_output' ? 'Read an owned command status and UTF-8 byte cursors. Reusing a cursor is read-only. Waiting never changes the command deadline.'
|
|
22
|
+
: name === 'stop_command' ? 'Request cancellation of one command launched by this Session. Read its output to confirm the final status; a cancel request is not proof of completion.'
|
|
23
|
+
: 'Report the bound command backend and its execution boundary.',
|
|
24
|
+
parameters: { type: 'object', properties, required, additionalProperties: false },
|
|
25
|
+
readOnly: !mutating, concurrentSafe: !mutating, permissions: mutating ? [name === 'stop_command' ? 'sandbox:cancel' : 'sandbox:execute'] : [],
|
|
26
|
+
timeoutMs, contextRole: 'none', observationRole: 'none', progressObservation: false, contextRetention: 'normal',
|
|
27
|
+
...(!mutating ? { recoveryPolicy: 'safe-read/v1' } : {}) });
|
|
28
|
+
export function localCommandToolDefinitions(settings = {}) {
|
|
29
|
+
const limits = localControlSettings(settings);
|
|
30
|
+
return [
|
|
31
|
+
base('sandbox_status', {}, [], false, 10000),
|
|
32
|
+
{ ...base('run_command', { command: { type: 'string', minLength: 1 }, cwd: { anyOf: [{ type: 'string' }, { type: 'null' }] },
|
|
33
|
+
timeout: schemas.nullableSeconds, env: { anyOf: [{ type: 'object', additionalProperties: { type: 'string' } }, { type: 'null' }] },
|
|
34
|
+
background: { type: 'boolean' }, background_timeout: schemas.nullableSeconds }, ['command'], true, limits.wallSeconds * 1000 + 15000),
|
|
35
|
+
backgroundCommandPolicy: 'owned-command-launch/v1', workspaceChangePolicy: 'observed-command/v1' },
|
|
36
|
+
base('read_command_output', { task_id: { type: 'string', pattern: '^[a-f0-9]{64}$' }, stdout_cursor: schemas.cursor,
|
|
37
|
+
stderr_cursor: schemas.cursor, max_bytes: { type: 'integer', minimum: 4, maximum: 65536 },
|
|
38
|
+
wait_seconds: { type: 'integer', minimum: 0, maximum: 600 } }, ['task_id'], false, 610000),
|
|
39
|
+
base('stop_command', { task_id: { type: 'string', pattern: '^[a-f0-9]{64}$' } }, ['task_id'], true, 10000),
|
|
40
|
+
];
|
|
41
|
+
}
|
|
42
|
+
const output = result => ({ status: result.status, stdout: result.stdout, stderr: result.stderr, exit_code: result.exitCode,
|
|
43
|
+
stdout_cursor: result.stdoutCursor, stderr_cursor: result.stderrCursor, cancel_requested: result.cancelRequested,
|
|
44
|
+
output_may_be_truncated: result.outputMayBeTruncated, evidence_incomplete: result.evidenceIncomplete,
|
|
45
|
+
observed_exit_code: result.observedExitCode, ...(result.outcomeReason ? { outcome_reason: result.outcomeReason } : {}),
|
|
46
|
+
...(result.commandEvidence ? { command_evidence: result.commandEvidence } : {}) });
|
|
47
|
+
const errorResult = (code, extra = {}) => ({ status: 'error', content: { ok: false, error_type: code, ...extra } });
|
|
48
|
+
const changed = (before, after) => [...new Set([...Object.keys(before.entries), ...Object.keys(after.entries)])]
|
|
49
|
+
.filter(path => before.entries[path] !== after.entries[path]).sort();
|
|
50
|
+
function utf8Preview(text, maxBytes) {
|
|
51
|
+
return new TextDecoder('utf8', { ignoreBOM: true }).decode(Buffer.from(text).subarray(0, maxBytes), { stream: true });
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Execute only after Host authorization. resolveOwnedCommand must validate
|
|
55
|
+
* canonical ledger receipts; a bare task ID or a backend environment switch
|
|
56
|
+
* grants no access. The caller keeps the surrounding Native intent/lease.
|
|
57
|
+
*/
|
|
58
|
+
export function createLocalCommandTransport({ workspaceRoot, stateRoot, sessionId, settings = {}, sourceEnv = {},
|
|
59
|
+
authorize, resolveOwnedCommand, captureWorkspace, artifacts, assertAuthority = () => true,
|
|
60
|
+
durableForeground = false, outputPolicy = null, environmentPolicy = null } = {}) {
|
|
61
|
+
invariant(typeof authorize === 'function' && typeof resolveOwnedCommand === 'function'
|
|
62
|
+
&& typeof captureWorkspace === 'function' && typeof assertAuthority === 'function'
|
|
63
|
+
&& typeof durableForeground === 'boolean', 'local_command_host_capabilities_missing');
|
|
64
|
+
// Validate raw policy inputs at this public entrypoint. The output policy
|
|
65
|
+
// forces the durable foreground path: full output lives in supervisor-owned
|
|
66
|
+
// files located by task_id/cursors, so an in-process runBoundedProcess receipt
|
|
67
|
+
// (which holds only a preview under persisted-stream/v1) must never be used.
|
|
68
|
+
const outPolicy = localOutputPolicy(outputPolicy);
|
|
69
|
+
const envPolicy = localEnvironmentPolicy(environmentPolicy);
|
|
70
|
+
if (outPolicy) durableForeground = true;
|
|
71
|
+
const root = realpathSync(workspaceRoot), policy = localControlSettings(settings), definitions = localCommandToolDefinitions(policy);
|
|
72
|
+
// Under persisted-stream/v1 the automatic foreground receipt uses a bounded
|
|
73
|
+
// window matching previewBytes (clamped to the 4..65536 UTF-8 cursor range).
|
|
74
|
+
// Explicit read_command_output retains its own independent pagination.
|
|
75
|
+
const previewWindow = outPolicy
|
|
76
|
+
? Math.min(65536, Math.max(4, resolveOutputPolicy(policy, outPolicy).previewBytes)) : null;
|
|
77
|
+
const environment = { ...sourceEnv }, formal = new Map(definitions.map(item => [item.name, copy(item)]));
|
|
78
|
+
// Exact model integers may be bigint. Clamp before converting rather than
|
|
79
|
+
// throwing after claiming a dispatch for an otherwise valid large timeout.
|
|
80
|
+
const deadline = seconds => (seconds == null || seconds > policy.wallSeconds ? policy.wallSeconds : Number(seconds)) * 1000;
|
|
81
|
+
// Canonicalize trusted Host state paths (e.g. macOS /var -> /private/var)
|
|
82
|
+
// before entering the backend's strict no-symlink private-directory guard.
|
|
83
|
+
const lexicalState = resolve(stateRoot);
|
|
84
|
+
invariant(lexicalState !== root && !lexicalState.startsWith(root + sep), 'background_state_inside_workspace');
|
|
85
|
+
mkdirSync(lexicalState, { recursive: true, mode: 0o700 });
|
|
86
|
+
const store = new LocalCommandStore({ stateRoot: realpathSync(lexicalState), workspaceRoot: root, sessionId });
|
|
87
|
+
const checkRoot = () => invariant(realpathSync(workspaceRoot) === root, 'workspace_root_changed');
|
|
88
|
+
function cwdFor(value) {
|
|
89
|
+
const cwd = value ? isAbsolute(value) ? resolve(value) : resolve(root, value) : root;
|
|
90
|
+
const rel = relative(root, cwd);
|
|
91
|
+
invariant(cwd === root || rel && !rel.startsWith('..' + sep) && rel !== '..' && !isAbsolute(rel), 'command_cwd_outside_workspace');
|
|
92
|
+
let part = root;
|
|
93
|
+
for (const name of rel.split(sep).filter(Boolean)) { part = join(part, name); invariant(!lstatSync(part).isSymbolicLink(), 'command_cwd_symlink'); }
|
|
94
|
+
invariant(lstatSync(cwd).isDirectory(), 'command_cwd_not_directory'); return cwd;
|
|
95
|
+
}
|
|
96
|
+
async function checkPlanMode(request) {
|
|
97
|
+
try {
|
|
98
|
+
checkRoot();
|
|
99
|
+
invariant(request.planMode === 'session-plan/v1' && request.definition.planModeCommandPolicy === PLAN_COMMAND_POLICY
|
|
100
|
+
&& request.call.name === 'run_command', 'plan_command_binding_invalid');
|
|
101
|
+
request.signal.throwIfAborted();
|
|
102
|
+
invariant(assertAuthority(request) === true, 'local_command_authority_stale');
|
|
103
|
+
const args = request.call.arguments, cwd = cwdFor(args.cwd || args.env?.KSADK_COMMAND_CWD);
|
|
104
|
+
const { KSADK_COMMAND_CWD: _routing, ...env } = args.env ?? {};
|
|
105
|
+
const allowed = await checkPlanCommand(args.command, { cwd, workspaceRoot: root, env, signal: request.signal });
|
|
106
|
+
request.signal.throwIfAborted(); checkRoot();
|
|
107
|
+
return allowed && assertAuthority(request) === true;
|
|
108
|
+
} catch { return false; }
|
|
109
|
+
}
|
|
110
|
+
async function budget(result, request) {
|
|
111
|
+
if (request.resultGovernance === 'runtime-artifacts/v1') return result;
|
|
112
|
+
const text = canonical(result.content);
|
|
113
|
+
if (Buffer.byteLength(text) <= 24000) return result;
|
|
114
|
+
invariant(typeof artifacts?.write === 'function', 'command_output_artifact_store_missing');
|
|
115
|
+
const ref = await artifacts.write({ ...request, operationKey: `${request.operationKey}:command-output`,
|
|
116
|
+
content: text, mediaType: 'application/json', metadata: { toolName: request.call.name, sessionId } });
|
|
117
|
+
const content = { ...result.content };
|
|
118
|
+
for (const key of ['command', 'stdout', 'stderr']) if (typeof content[key] === 'string') content[key] = utf8Preview(content[key], 4000);
|
|
119
|
+
content.output_budget = { truncated: true, artifact_uri: ref.uri, size_bytes: ref.sizeBytes,
|
|
120
|
+
instruction: 'The complete retained result is in the artifact. Output byte cursors refer to the backend stream, not this preview.' };
|
|
121
|
+
return { ...result, content, artifactRefs: [...result.artifactRefs ?? [], ref] };
|
|
122
|
+
}
|
|
123
|
+
// Observation is an execution-local Host capability, outside the immutable
|
|
124
|
+
// request and the final receipt. It is never published to a client here:
|
|
125
|
+
// the Host must authorize/project these bytes before displaying a preview.
|
|
126
|
+
async function execute(request, { onProgress } = {}) {
|
|
127
|
+
let dispatched = false, known, original, sealed;
|
|
128
|
+
let progressSequence = 0;
|
|
129
|
+
const guard = () => {
|
|
130
|
+
checkRoot();
|
|
131
|
+
invariant(request.signal === original && !original.aborted, 'local_command_signal_stale');
|
|
132
|
+
const { signal: _, ...body } = request;
|
|
133
|
+
invariant(canonical(body) === sealed, 'local_command_request_drifted');
|
|
134
|
+
const current = assertAuthority(request);
|
|
135
|
+
if (current && typeof current.then === 'function') Promise.resolve(current).catch(() => {});
|
|
136
|
+
invariant(current === true, 'local_command_authority_stale');
|
|
137
|
+
const { signal: liveSignal, ...live } = request;
|
|
138
|
+
invariant(liveSignal === original && canonical(live) === sealed, 'local_command_request_drifted');
|
|
139
|
+
original.throwIfAborted(); return true;
|
|
140
|
+
};
|
|
141
|
+
const progress = (channel, text) => {
|
|
142
|
+
if (typeof onProgress !== 'function' || !text || original?.aborted) return;
|
|
143
|
+
try {
|
|
144
|
+
guard();
|
|
145
|
+
const observed = onProgress(Object.freeze({ contract: LOCAL_COMMAND_PROGRESS_CONTRACT,
|
|
146
|
+
sequence: ++progressSequence, channel, text }));
|
|
147
|
+
// A presentation consumer cannot delay or fail the command, including
|
|
148
|
+
// when it returns a rejecting promise instead of throwing directly.
|
|
149
|
+
Promise.resolve(observed).catch(() => {});
|
|
150
|
+
} catch { /* unavailable/stale observations grant no execution outcome */ }
|
|
151
|
+
};
|
|
152
|
+
try {
|
|
153
|
+
invariant(request.signal instanceof AbortSignal, 'local_command_signal_required');
|
|
154
|
+
original = request.signal;
|
|
155
|
+
const { signal: _, ...body } = request; sealed = canonical(body);
|
|
156
|
+
invariant(request.sessionId === sessionId, 'local_command_session_mismatch'); checkRoot(); request.signal.throwIfAborted();
|
|
157
|
+
guard();
|
|
158
|
+
const definition = formal.get(request.call?.name); invariant(definition, 'local_command_tool_unbound');
|
|
159
|
+
const rejection = toolInputRejection(definition, request.call);
|
|
160
|
+
if (rejection) return { status: 'error', content: rejection };
|
|
161
|
+
const args = structuredClone(request.call.arguments);
|
|
162
|
+
const decision = await authorize(request); guard();
|
|
163
|
+
if (decision !== 'allow') return decision?.status === 'approval_required' && typeof decision.interactionId === 'string'
|
|
164
|
+
? decision : errorResult('policy_denied');
|
|
165
|
+
if (request.call.name === 'sandbox_status') return { status: 'ok', content: { ok: true, backend: 'local_process', enabled: true,
|
|
166
|
+
isolated: false, sandbox_id: `local_process:${sessionId}`, timeout_seconds: policy.wallSeconds,
|
|
167
|
+
boundary: 'Shares Host filesystem, allowlisted environment and network. Not an OS security sandbox.',
|
|
168
|
+
shell: '/bin/bash', shell_policy: LOCAL_COMMAND_SHELL,
|
|
169
|
+
contracts: [LOCAL_COMMAND_TOOLS_CONTRACT, LOCAL_PROCESS_CONTRACT, LOCAL_COMMAND_POLICY, GOVERNED_COMMAND_POLICY] } };
|
|
170
|
+
if (request.call.name === 'read_command_output' || request.call.name === 'stop_command') {
|
|
171
|
+
const owned = resolveOwnedCommand(request, args.task_id);
|
|
172
|
+
guard();
|
|
173
|
+
invariant(owned.backend === 'local_process' && owned.ref.taskId === store.commandId(owned.ref.operationKey), 'local_command_backend_mismatch');
|
|
174
|
+
const handle = store.connect(owned.ref.taskId);
|
|
175
|
+
if (request.call.name === 'stop_command') {
|
|
176
|
+
guard();
|
|
177
|
+
dispatched = true; known = { status: 'ok', content: { ok: true, task_id: args.task_id, ...output(handle.requestCancel()) } };
|
|
178
|
+
} else {
|
|
179
|
+
const until = performance.now() + (args.wait_seconds ?? 0) * 1000;
|
|
180
|
+
let interval = 50;
|
|
181
|
+
while (!TERMINAL.has(handle.readOutput().status) && performance.now() < until) {
|
|
182
|
+
guard(); await delay(Math.min(interval, Math.max(0, until - performance.now()))); interval = Math.min(1000, interval * 2);
|
|
183
|
+
}
|
|
184
|
+
request.signal.throwIfAborted();
|
|
185
|
+
known = { status: 'ok', content: { ok: true, task_id: args.task_id, ...output(handle.readOutput({
|
|
186
|
+
stdoutCursor: args.stdout_cursor ?? 0, stderrCursor: args.stderr_cursor ?? 0, maxBytes: args.max_bytes ?? 65536 })) } };
|
|
187
|
+
}
|
|
188
|
+
guard(); const result = await budget(known, request); guard(); return result;
|
|
189
|
+
}
|
|
190
|
+
const command = args.command.trim();
|
|
191
|
+
if (!command) return errorResult('command_required');
|
|
192
|
+
if (!args.background && args.background_timeout != null) return errorResult('background_timeout_requires_background');
|
|
193
|
+
const cwd = cwdFor(args.cwd || args.env?.KSADK_COMMAND_CWD);
|
|
194
|
+
const finding = await checkGovernedCommand(command, { cwd, allowedRoots: [root], signal: request.signal });
|
|
195
|
+
guard();
|
|
196
|
+
if (finding) return errorResult('command_policy_blocked', { finding, exit_code: 126, executed: false });
|
|
197
|
+
if (request.planMode !== undefined && !await checkPlanMode(request))
|
|
198
|
+
return { status: 'error', content: { code: 'plan_mode_write_denied', executed: false } };
|
|
199
|
+
guard();
|
|
200
|
+
request.signal.throwIfAborted(); checkRoot(); cwdFor(args.cwd || args.env?.KSADK_COMMAND_CWD);
|
|
201
|
+
const env = buildScriptEnvironment({ ...environment, ...args.env }, { settings: policy, environmentPolicy: envPolicy });
|
|
202
|
+
const observedChanges = request.definition?.workspaceChangePolicy === 'observed-command/v1';
|
|
203
|
+
const before = observedChanges ? await captureCommandWorkspace(captureWorkspace, request) : captureWorkspace(request);
|
|
204
|
+
guard();
|
|
205
|
+
if (args.background || durableForeground) {
|
|
206
|
+
dispatched = true;
|
|
207
|
+
const operationKey = request.operationKey, expectedTaskId = store.commandId(operationKey);
|
|
208
|
+
known = { content: { backend: 'sandbox/local_process', task_id: expectedTaskId } };
|
|
209
|
+
const handle = await store.start({ operationKey, command, cwd, env, settings: policy, shellPolicy: LOCAL_COMMAND_SHELL,
|
|
210
|
+
outputPolicy: outPolicy, environmentPolicy: envPolicy,
|
|
211
|
+
timeoutMs: deadline(args.background ? args.background_timeout : args.timeout), assertCurrent: guard });
|
|
212
|
+
if (!args.background) {
|
|
213
|
+
const cancel = () => { try { handle.requestCancel(); } catch { /* retain unknown */ } };
|
|
214
|
+
original.addEventListener('abort', cancel, { once: true });
|
|
215
|
+
if (original.aborted) cancel();
|
|
216
|
+
try {
|
|
217
|
+
const until = performance.now() + deadline(args.timeout) + 15000;
|
|
218
|
+
let stdoutCursor = 0, stderrCursor = 0;
|
|
219
|
+
while (performance.now() < until) {
|
|
220
|
+
guard();
|
|
221
|
+
const view = handle.readOutput({ stdoutCursor, stderrCursor });
|
|
222
|
+
const advanced = view.stdoutCursor > stdoutCursor || view.stderrCursor > stderrCursor;
|
|
223
|
+
if (typeof onProgress === 'function') {
|
|
224
|
+
progress('stdout', view.stdout); progress('stderr', view.stderr);
|
|
225
|
+
stdoutCursor = view.stdoutCursor; stderrCursor = view.stderrCursor;
|
|
226
|
+
}
|
|
227
|
+
// A completed producer may still have several retained windows.
|
|
228
|
+
// Drain them before closing the observation, without deriving a
|
|
229
|
+
// successful receipt from the preview's terminal status.
|
|
230
|
+
if (TERMINAL.has(view.status) && (typeof onProgress !== 'function' || !advanced)) break;
|
|
231
|
+
await delay(20);
|
|
232
|
+
}
|
|
233
|
+
} catch (error) { cancel(); throw error;
|
|
234
|
+
} finally { original.removeEventListener('abort', cancel); }
|
|
235
|
+
}
|
|
236
|
+
// The automatic foreground receipt is a bounded PREVIEW under the new
|
|
237
|
+
// policy (previewWindow bytes); full output stays cursor-readable via
|
|
238
|
+
// read_command_output's independent pagination. wait() reads the whole
|
|
239
|
+
// file in legacy mode only — under the policy it must not pull the full
|
|
240
|
+
// output into the receipt, so re-read with the bounded window instead.
|
|
241
|
+
let observed = previewWindow ? handle.readOutput({ maxBytes: previewWindow })
|
|
242
|
+
: handle.readOutput();
|
|
243
|
+
if (!args.background && TERMINAL.has(observed.status) && observed.status !== 'unknown'
|
|
244
|
+
&& !previewWindow)
|
|
245
|
+
observed = await handle.wait({ timeoutMs: 100 });
|
|
246
|
+
const uncertain = observed.status === 'unknown' || observed.evidenceIncomplete
|
|
247
|
+
|| !args.background && !TERMINAL.has(observed.status);
|
|
248
|
+
known = { status: uncertain ? 'uncertain' : 'ok', content: { ok: !uncertain, backend: 'sandbox/local_process', sandbox_id: `local_process:${sessionId}`,
|
|
249
|
+
task_id: handle.commandId, ...output(observed), process_timeout_seconds: readRecord(handle.directory, store.owner, handle.commandId).timeoutMs / 1000,
|
|
250
|
+
...(!args.background ? { command, controls: readRecord(handle.directory, store.owner, handle.commandId).controls ?? null } : {}),
|
|
251
|
+
instruction: 'Use read_command_output with task_id; stop_command cancels this task only. Do not relaunch unknown outcomes.' },
|
|
252
|
+
...(args.background ? { launchedCommand: { taskId: handle.commandId, operationKey } } : {}) };
|
|
253
|
+
} else {
|
|
254
|
+
dispatched = true;
|
|
255
|
+
const result = await runBoundedProcess({ argv: shellCommandArgv(command, LOCAL_COMMAND_SHELL), cwd, env, settings: policy, signal: request.signal,
|
|
256
|
+
timeoutMs: deadline(args.timeout), onOutput: progress });
|
|
257
|
+
const incomplete = result.controls.status === 'failed' || !!result.cleanupError;
|
|
258
|
+
const resourceReason = result.exitCode === -osConstants.signals.SIGXCPU ? 'cpu_time'
|
|
259
|
+
: result.exitCode === -osConstants.signals.SIGXFSZ ? 'file_size' : null;
|
|
260
|
+
const diagnostics = [result.timedOut ? 'command timed out' : '', result.outputLimitExceeded ? 'command output limit exceeded' : '',
|
|
261
|
+
resourceReason ? `command resource limit exceeded: ${resourceReason}` : '',
|
|
262
|
+
incomplete ? 'command process supervision or cleanup failed' : ''].filter(Boolean).join('; ');
|
|
263
|
+
known = { status: incomplete ? 'uncertain' : 'ok', content: { ok: true, backend: 'sandbox/local_process',
|
|
264
|
+
sandbox_id: `local_process:${sessionId}`, command, stdout: result.stdout,
|
|
265
|
+
stderr: result.stderr + (diagnostics ? (result.stderr ? '\n' : '') + diagnostics : ''),
|
|
266
|
+
exit_code: incomplete ? null : result.timedOut ? 124 : result.outputLimitExceeded ? 122 : result.exitCode,
|
|
267
|
+
observed_exit_code: incomplete ? result.exitCode : null, evidence_incomplete: incomplete, controls: result.controls,
|
|
268
|
+
...(incomplete ? { command_evidence: commandFailureEvidence('execute', { result }) } : {}) } };
|
|
269
|
+
}
|
|
270
|
+
guard();
|
|
271
|
+
const after = observedChanges ? await captureCommandWorkspace(captureWorkspace, request) : captureWorkspace(request);
|
|
272
|
+
if (observedChanges) {
|
|
273
|
+
Object.assign(known, commandWorkspaceChange(before, after));
|
|
274
|
+
known.content.workspace_observation = known.workspaceChangeObservation;
|
|
275
|
+
} else known.workspaceChangedPaths = changed(before, after);
|
|
276
|
+
guard(); const result = await budget(known, request); guard(); return result;
|
|
277
|
+
} catch (error) {
|
|
278
|
+
// After dispatch, an exception cannot prove that a command did not run.
|
|
279
|
+
// Keep any known result, including its exit code and background locator.
|
|
280
|
+
return dispatched ? { ...known, status: 'uncertain', content: { ...known?.content, ok: false,
|
|
281
|
+
error_type: 'command_evidence_incomplete', evidence_incomplete: true, observed_result: known?.content?.status ?? null,
|
|
282
|
+
command_evidence: commandFailureEvidence('transport', { previous: known?.content?.command_evidence,
|
|
283
|
+
result: { exitCode: known?.content?.observed_exit_code ?? known?.content?.exit_code ?? null },
|
|
284
|
+
failures: [commandExceptionCause(error, 'transport_exception')] }) } }
|
|
285
|
+
: errorResult(request?.signal?.aborted ? 'canceled' : error.code ?? 'local_command_error', { executed: false });
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
return Object.freeze({ get definitions() { return copy(definitions); }, execute, checkPlanMode,
|
|
289
|
+
commandId: operationKey => store.commandId(operationKey), reader: createLocalBackgroundReader(store) });
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/** Native adds its real launch Run only here. The shared transport also serves
|
|
293
|
+
* DSH/Codex calls with their actual Session/call identity, without a fake Run. */
|
|
294
|
+
export function createNativeLocalCommandTools(options = {}) {
|
|
295
|
+
const transport = createLocalCommandTransport(options);
|
|
296
|
+
return Object.freeze({ get definitions() { return transport.definitions; }, reader: transport.reader, checkPlanMode: transport.checkPlanMode,
|
|
297
|
+
async execute(request, observations) {
|
|
298
|
+
const sourceRunId = request?.runId;
|
|
299
|
+
const { launchedCommand, ...result } = await transport.execute(request, observations);
|
|
300
|
+
return { ...result, ...(launchedCommand ? { backgroundCommandRef: { ...launchedCommand, sourceRunId } } : {}) };
|
|
301
|
+
} });
|
|
302
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { buildScriptEnvironment, LOCAL_PROCESS_CONTRACT } from '@kingsoftcloud/agentkit-harness-node/native/local-process';
|
|
2
|
+
import { localOutputPolicy, localEnvironmentPolicy } from '@kingsoftcloud/agentkit-harness-node/native/local-output-policy';
|
|
3
|
+
import { LOCAL_COMMAND_TOOLS_CONTRACT } from '@kingsoftcloud/agentkit-harness-node/native/local-command-tools';
|
|
4
|
+
import { LOCAL_COMMAND_POLICY, GOVERNED_COMMAND_POLICY, LOCAL_COMMAND_SHELL } from '@kingsoftcloud/agentkit-harness-node/native/local-command-policy';
|
|
5
|
+
import { canonical } from '@kingsoftcloud/agentkit-harness-core';
|
|
6
|
+
import { createHash } from 'node:crypto';
|
|
7
|
+
import { localResourceSettings, localResourceFacts } from './local-resource-profile.mjs';
|
|
8
|
+
const hash = value => createHash('sha256').update(canonical(value)).digest('hex');
|
|
9
|
+
|
|
10
|
+
/** Select a Host execution profile once, then use the same settings for binding, facts and actual transport. */
|
|
11
|
+
export function bindNativeLocalExecution({ settings = {}, sourceEnv = {}, modern = false, resourceProfile } = {}) {
|
|
12
|
+
const selected = localResourceSettings({ settings, profile: resourceProfile });
|
|
13
|
+
const outputPolicy = modern ? localOutputPolicy({ version: 'persisted-stream/v1',
|
|
14
|
+
previewBytes: Math.max(4, Math.min(selected.maxOutputBytes, 16384)), persistedBytes: 64 * 1024 * 1024 }) : null;
|
|
15
|
+
const environmentPolicy = modern ? localEnvironmentPolicy({ version: 'local-development/v1' }) : null;
|
|
16
|
+
const environment = buildScriptEnvironment(sourceEnv, { settings: selected, environmentPolicy });
|
|
17
|
+
return { settings: selected, outputPolicy, environmentPolicy, environment,
|
|
18
|
+
...(resourceProfile === undefined ? {} : { resourceProfile }), binding: {
|
|
19
|
+
contract: LOCAL_COMMAND_TOOLS_CONTRACT, controls: { contract: LOCAL_PROCESS_CONTRACT, settings: selected, shellPolicy: LOCAL_COMMAND_SHELL,
|
|
20
|
+
...(modern ? { outputPolicy, environmentPolicy } : {}),
|
|
21
|
+
...(resourceProfile === undefined ? {} : { resourceProfile }) },
|
|
22
|
+
risk: [LOCAL_COMMAND_POLICY, GOVERNED_COMMAND_POLICY], backend: 'local_process', environmentDigest: hash(environment),
|
|
23
|
+
} };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** The same selected execution profile supplies both transport bindings and model facts. */
|
|
27
|
+
export function nativeLocalExecutionMethods({ enabled, settings, sourceEnv, workspaceRoot, commandBackend }) {
|
|
28
|
+
const localExecution = ({ modern = false, resourceProfile } = {}) => enabled
|
|
29
|
+
? bindNativeLocalExecution({ settings, sourceEnv, modern, resourceProfile }) : null;
|
|
30
|
+
return { localExecution, executionEnvironment({ modern = false, resourceProfile } = {}) {
|
|
31
|
+
const local = localExecution({ modern, resourceProfile });
|
|
32
|
+
return { workspaceRoot, cwd: workspaceRoot, platform: process.platform, commandBackend,
|
|
33
|
+
...(local ? { commandCwd: workspaceRoot, environmentNames: Object.keys(local.environment).sort(),
|
|
34
|
+
...(resourceProfile === undefined ? {} : { commandResources: localResourceFacts(resourceProfile, local.settings) }) } : {}) };
|
|
35
|
+
} };
|
|
36
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
// Versioned, opt-in execution-boundary policies. These are NOT keys in
|
|
2
|
+
// localControlSettings: that object is normalized and enters the command
|
|
3
|
+
// request digest, so adding keys there would change every historical digest.
|
|
4
|
+
// The policy travels as a separate optional field, preserving exact legacy
|
|
5
|
+
// serialization and digests when absent.
|
|
6
|
+
import { LocalProcessError } from './local-process.mjs';
|
|
7
|
+
|
|
8
|
+
export const OUTPUT_POLICY_CONTRACT = 'agentkit.output-policy/v1';
|
|
9
|
+
export const outputPolicyVersion = 'persisted-stream/v1';
|
|
10
|
+
export const ENVIRONMENT_POLICY_CONTRACT = 'agentkit.environment-policy/v1';
|
|
11
|
+
export const environmentPolicyVersion = 'local-development/v1';
|
|
12
|
+
|
|
13
|
+
// The only environment names a local-development policy may add. Never the full
|
|
14
|
+
// process.env and never arbitrary credentials. HOME/USERPROFILE give the command
|
|
15
|
+
// a home for dotfiles/cache; USER/LOGNAME/TMPDIR are basic identity/temp names.
|
|
16
|
+
export const BASIC_ENVIRONMENT_NAMES = Object.freeze(['HOME', 'USER', 'LOGNAME', 'TMPDIR', 'USERPROFILE']);
|
|
17
|
+
const basicNameSet = new Set(BASIC_ENVIRONMENT_NAMES);
|
|
18
|
+
|
|
19
|
+
const HARD_PERSISTED_CAP_BYTES = 64 * 1024 ** 2;
|
|
20
|
+
const isPositiveInt = value => Number.isSafeInteger(value) && value > 0;
|
|
21
|
+
// Enforced bounds: preview/persisted caps must be sane and persisted >= preview
|
|
22
|
+
// so the hard cap is always the real boundary, never the preview.
|
|
23
|
+
const checkBytes = (value, code, min = 1, max = HARD_PERSISTED_CAP_BYTES * 16) => {
|
|
24
|
+
if (!isPositiveInt(value) || value < min || value > max) throw new LocalProcessError(code);
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
/** Parse an opt-in output policy. `null`/`undefined` => legacy semantics.
|
|
28
|
+
* Rejects unknown options and invalid limits. Frozen, deterministic, and omits
|
|
29
|
+
* undefined properties so canonical serialization (which rejects undefined)
|
|
30
|
+
* never sees them. */
|
|
31
|
+
export function localOutputPolicy(input = null) {
|
|
32
|
+
if (input == null) return null;
|
|
33
|
+
if (typeof input !== 'object' || Array.isArray(input)) throw new LocalProcessError('invalid_output_policy');
|
|
34
|
+
const keys = Object.keys(input);
|
|
35
|
+
if (!keys.every(key => key === 'version' || key === 'previewBytes' || key === 'persistedBytes'))
|
|
36
|
+
throw new LocalProcessError('invalid_output_policy_option');
|
|
37
|
+
const version = input.version ?? outputPolicyVersion;
|
|
38
|
+
if (version !== outputPolicyVersion) throw new LocalProcessError('unsupported_output_policy_version');
|
|
39
|
+
const hasPreview = Object.hasOwn(input, 'previewBytes'), hasPersisted = Object.hasOwn(input, 'persistedBytes');
|
|
40
|
+
if (hasPreview) checkBytes(input.previewBytes, 'invalid_output_policy_preview');
|
|
41
|
+
if (hasPersisted) checkBytes(input.persistedBytes, 'invalid_output_policy_persisted');
|
|
42
|
+
// Do NOT reject persistedBytes < previewBytes here: resolveOutputPolicy clamps
|
|
43
|
+
// the preview down to the persisted cap, so a caller may set a large preview and
|
|
44
|
+
// a smaller persisted cap and still get a sane bounded preview. Both limits are
|
|
45
|
+
// individually valid positive integers; the relationship is resolved, not parsed.
|
|
46
|
+
// Omit undefined properties: canonical serialization rejects undefined, and a
|
|
47
|
+
// clean object is digestable/deterministic across fresh vs legacy bindings.
|
|
48
|
+
const policy = { version };
|
|
49
|
+
if (hasPreview) policy.previewBytes = input.previewBytes;
|
|
50
|
+
if (hasPersisted) policy.persistedBytes = input.persistedBytes;
|
|
51
|
+
return Object.freeze(policy);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Parse an opt-in environment policy. `null`/`undefined` => legacy allowlist.
|
|
55
|
+
* The local-development policy may ONLY add the basic names; it cannot inject
|
|
56
|
+
* arbitrary names or credentials. Rejects unknown options. */
|
|
57
|
+
export function localEnvironmentPolicy(input = null) {
|
|
58
|
+
if (input == null) return null;
|
|
59
|
+
if (typeof input !== 'object' || Array.isArray(input)) throw new LocalProcessError('invalid_environment_policy');
|
|
60
|
+
const keys = Object.keys(input);
|
|
61
|
+
if (!keys.every(key => key === 'version' || key === 'names'))
|
|
62
|
+
throw new LocalProcessError('invalid_environment_policy_option');
|
|
63
|
+
const version = input.version ?? environmentPolicyVersion;
|
|
64
|
+
if (version !== environmentPolicyVersion) throw new LocalProcessError('unsupported_environment_policy_version');
|
|
65
|
+
let names = BASIC_ENVIRONMENT_NAMES;
|
|
66
|
+
if (Object.hasOwn(input, 'names')) {
|
|
67
|
+
if (!Array.isArray(input.names) || input.names.some(name => typeof name !== 'string' || !basicNameSet.has(name)))
|
|
68
|
+
throw new LocalProcessError('invalid_environment_policy_names');
|
|
69
|
+
names = Object.freeze([...new Set(input.names)]);
|
|
70
|
+
}
|
|
71
|
+
return Object.freeze({ version, names });
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Resolve an output policy against control settings for runBoundedProcess.
|
|
75
|
+
* Returns { mode, previewBytes, persistedBytes }. Legacy mode preserves the
|
|
76
|
+
* exact maxOutputBytes-then-kill semantics. */
|
|
77
|
+
export function resolveOutputPolicy(settings, outputPolicy) {
|
|
78
|
+
if (!outputPolicy) return { mode: 'legacy', previewBytes: settings.maxOutputBytes, persistedBytes: settings.maxOutputBytes };
|
|
79
|
+
const previewBytes = outputPolicy.previewBytes ?? settings.maxOutputBytes;
|
|
80
|
+
const persistedBytes = outputPolicy.persistedBytes ?? HARD_PERSISTED_CAP_BYTES;
|
|
81
|
+
// previewBytes may exceed the legacy default but can never exceed the persisted cap.
|
|
82
|
+
return { mode: 'persistedStream', previewBytes: Math.min(previewBytes, persistedBytes), persistedBytes };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Resolve an environment policy against control settings for buildScriptEnvironment.
|
|
86
|
+
* Returns the set of additional names to admit beyond the restrictive allowlist. */
|
|
87
|
+
export function resolveEnvironmentPolicy(settings, environmentPolicy) {
|
|
88
|
+
if (!environmentPolicy) return { names: [] };
|
|
89
|
+
return { names: [...environmentPolicy.names] };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export const OUTPUT_POLICY_HARD_PERSISTED_CAP_BYTES = HARD_PERSISTED_CAP_BYTES;
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|