@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.
Files changed (151) hide show
  1. package/LICENSE +201 -0
  2. package/NOTICE +1 -0
  3. package/README.md +124 -2
  4. package/THIRD_PARTY_NOTICES.md +66 -0
  5. package/ZCode-LICENSE +202 -0
  6. package/dist/index.mjs +4871 -0
  7. package/dist/native-command-plan.LICENSE.txt +221 -0
  8. package/dist/native-command-plan.json +16 -0
  9. package/dist/native-command-plan.mjs +8 -0
  10. package/dist/rpc-entry.mjs +5682 -0
  11. package/package.json +169 -4
  12. package/src/native/a2a-receipt-source.mjs +19 -0
  13. package/src/native/a2a-tools.mjs +517 -0
  14. package/src/native/activity-status.mjs +30 -0
  15. package/src/native/artifact-codec.mjs +481 -0
  16. package/src/native/artifact-store.mjs +1162 -0
  17. package/src/native/assistant-stream.mjs +76 -0
  18. package/src/native/background-command-port.mjs +95 -0
  19. package/src/native/browser-action-observation.mjs +43 -0
  20. package/src/native/browser-availability.mjs +22 -0
  21. package/src/native/browser-canvas-metrics.mjs +71 -0
  22. package/src/native/browser-dom.mjs +193 -0
  23. package/src/native/browser-playwright.mjs +1249 -0
  24. package/src/native/browser-vision.mjs +369 -0
  25. package/src/native/browser-workspace-policy.mjs +22 -0
  26. package/src/native/browser-workspace-protocol.mjs +95 -0
  27. package/src/native/browser-workspace-server.mjs +164 -0
  28. package/src/native/browser-workspace.mjs +355 -0
  29. package/src/native/captured-source.mjs +3 -0
  30. package/src/native/child-budget-allocation.mjs +87 -0
  31. package/src/native/child-capabilities.mjs +35 -0
  32. package/src/native/child-command-resource.mjs +20 -0
  33. package/src/native/child-history.mjs +188 -0
  34. package/src/native/child-merge.mjs +98 -0
  35. package/src/native/child-model-surface.mjs +19 -0
  36. package/src/native/child-observation.mjs +100 -0
  37. package/src/native/child-owner.mjs +620 -0
  38. package/src/native/child-publication.mjs +76 -0
  39. package/src/native/child-readers.mjs +366 -0
  40. package/src/native/child-tools.mjs +46 -0
  41. package/src/native/child-workspace-binding.mjs +29 -0
  42. package/src/native/child-workspace-tools.mjs +1 -0
  43. package/src/native/child-workspaces.mjs +655 -0
  44. package/src/native/children.mjs +365 -0
  45. package/src/native/command-plan-worker.mjs +14 -0
  46. package/src/native/command-plan.mjs +31 -0
  47. package/src/native/command-progress.mjs +64 -0
  48. package/src/native/command-shell.mjs +9 -0
  49. package/src/native/command-workspace-observation.mjs +36 -0
  50. package/src/native/component-tools.mjs +280 -0
  51. package/src/native/context-counter.mjs +133 -0
  52. package/src/native/context-markers.mjs +51 -0
  53. package/src/native/context-profile.mjs +72 -0
  54. package/src/native/context-status.mjs +27 -0
  55. package/src/native/conversation-command-control.mjs +133 -0
  56. package/src/native/delivery-timeline.mjs +39 -0
  57. package/src/native/dsh-background-ownership.mjs +102 -0
  58. package/src/native/dsh-managed-ownership.mjs +23 -0
  59. package/src/native/dsh-turn-contract.mjs +3 -0
  60. package/src/native/dsh-turn-ownership.mjs +54 -0
  61. package/src/native/dsh-turn-runtime.mjs +498 -0
  62. package/src/native/e2b-background-supervisor.mjs +347 -0
  63. package/src/native/e2b-background.mjs +368 -0
  64. package/src/native/e2b-command-state.mjs +40 -0
  65. package/src/native/e2b-command-tools.mjs +486 -0
  66. package/src/native/e2b-sandbox-owner.mjs +227 -0
  67. package/src/native/hook-executor.mjs +249 -0
  68. package/src/native/instruction-assembly.mjs +60 -0
  69. package/src/native/instruction-context.mjs +54 -0
  70. package/src/native/local-background-supervisor.mjs +108 -0
  71. package/src/native/local-background.mjs +207 -0
  72. package/src/native/local-command-evidence.mjs +57 -0
  73. package/src/native/local-command-policy-worker.mjs +197 -0
  74. package/src/native/local-command-policy.mjs +46 -0
  75. package/src/native/local-command-state.mjs +177 -0
  76. package/src/native/local-command-tools.mjs +302 -0
  77. package/src/native/local-execution-profile.mjs +36 -0
  78. package/src/native/local-output-policy.mjs +92 -0
  79. package/src/native/local-process-bin/darwin-arm64/launcher +0 -0
  80. package/src/native/local-process-bin/darwin-x64/launcher +0 -0
  81. package/src/native/local-process-bin/linux-arm64/launcher +0 -0
  82. package/src/native/local-process-bin/linux-x64/launcher +0 -0
  83. package/src/native/local-process-launcher.c +183 -0
  84. package/src/native/local-process-licenses/GCC-COPYING.RUNTIME +72 -0
  85. package/src/native/local-process-licenses/GCC-COPYING3 +674 -0
  86. package/src/native/local-process-licenses/NOTICE +21 -0
  87. package/src/native/local-process-licenses/musl-COPYRIGHT +193 -0
  88. package/src/native/local-process-manifest.json +32 -0
  89. package/src/native/local-process.mjs +246 -0
  90. package/src/native/local-resource-profile.mjs +32 -0
  91. package/src/native/memory-candidate.mjs +121 -0
  92. package/src/native/memory-extraction-run.mjs +60 -0
  93. package/src/native/memory-extraction-tools.mjs +116 -0
  94. package/src/native/memory-recall.mjs +65 -0
  95. package/src/native/memory-store.mjs +334 -0
  96. package/src/native/memory-tools.mjs +329 -0
  97. package/src/native/model-anthropic-cache.mjs +49 -0
  98. package/src/native/model-anthropic-tool-search.mjs +85 -0
  99. package/src/native/model-anthropic.mjs +753 -0
  100. package/src/native/model-chat-usage.mjs +24 -0
  101. package/src/native/model-inline-reasoning.mjs +111 -0
  102. package/src/native/model-media.mjs +174 -0
  103. package/src/native/model-normalize.mjs +228 -0
  104. package/src/native/model-openai-chat.mjs +561 -0
  105. package/src/native/model-port.mjs +361 -0
  106. package/src/native/model-provider-compat.mjs +129 -0
  107. package/src/native/model-replay.mjs +153 -0
  108. package/src/native/model-request.mjs +15 -0
  109. package/src/native/model-responses.mjs +1205 -0
  110. package/src/native/model-sse.mjs +171 -0
  111. package/src/native/model-surface-host.mjs +27 -0
  112. package/src/native/model-tool-search-policy.mjs +16 -0
  113. package/src/native/model-transport-observation.mjs +27 -0
  114. package/src/native/owned-child.mjs +114 -0
  115. package/src/native/owned-command.mjs +106 -0
  116. package/src/native/python-syntax-worker.mjs +60 -0
  117. package/src/native/python-syntax.mjs +50 -0
  118. package/src/native/read-interruption.mjs +16 -0
  119. package/src/native/result-stream.mjs +63 -0
  120. package/src/native/ruff-ast.mjs +95 -0
  121. package/src/native/run-observation.mjs +128 -0
  122. package/src/native/session-delivery-journal.mjs +96 -0
  123. package/src/native/session-delivery.mjs +434 -0
  124. package/src/native/shell-analysis.mjs +50 -0
  125. package/src/native/skill-disclosure.mjs +404 -0
  126. package/src/native/skill-events.mjs +164 -0
  127. package/src/native/skill-tools.mjs +813 -0
  128. package/src/native/source-page.mjs +73 -0
  129. package/src/native/sqlite-session-inbox.mjs +42 -0
  130. package/src/native/sqlite-store.mjs +617 -0
  131. package/src/native/tool-authority.mjs +99 -0
  132. package/src/native/tool-guidance.mjs +12 -0
  133. package/src/native/tool-observation.mjs +75 -0
  134. package/src/native/tool-states.mjs +14 -0
  135. package/src/native/verification-authority.mjs +37 -0
  136. package/src/native/web-tools.mjs +643 -0
  137. package/src/native/workspace-checkpoints.mjs +312 -0
  138. package/src/native/workspace-context.mjs +61 -0
  139. package/src/native/workspace-display.mjs +39 -0
  140. package/src/native/workspace-edit-match.mjs +19 -0
  141. package/src/native/workspace-edit-worker.mjs +15 -0
  142. package/src/native/workspace-editing.mjs +447 -0
  143. package/src/native/workspace-image.mjs +72 -0
  144. package/src/native/workspace-instructions.mjs +107 -0
  145. package/src/native/workspace-mutation-lock.mjs +74 -0
  146. package/src/native/workspace-patch-worker.mjs +17 -0
  147. package/src/native/workspace-patch.mjs +17 -0
  148. package/src/native/workspace-paths.mjs +225 -0
  149. package/src/native/workspace-state.mjs +272 -0
  150. package/src/native/workspace-subject-observer.mjs +84 -0
  151. package/src/native/workspace-tools.mjs +1740 -0
@@ -0,0 +1,74 @@
1
+ /** Cooperative cross-process exclusion for synchronous workspace publications.
2
+ * Never hold this lock across an await: a same-process contender would block
3
+ * the event loop needed by its owner. SQLite releases it on process death.
4
+ */
5
+ import { createHash } from 'node:crypto';
6
+ import { closeSync, existsSync, realpathSync } from 'node:fs';
7
+ import { basename, dirname, join, resolve } from 'node:path';
8
+ import { DatabaseSync } from 'node:sqlite';
9
+ import { constants } from 'node:fs';
10
+ import { canonical } from '@kingsoftcloud/agentkit-harness-core';
11
+ import { openPrivate, privateDirectory, privateFile } from './local-command-state.mjs';
12
+
13
+ export const WORKSPACE_MUTATION_LOCK = 'agentkit.workspace-mutation-lock/v1';
14
+ const failure = code => Object.assign(new Error(code), { code });
15
+ // Keep the native handle strongly reachable even when a caller only uses the
16
+ // returned release function for its side effect. V8 may otherwise collect an
17
+ // unused closure (and its DatabaseSync) while the lock is intentionally held.
18
+ const activeDatabases = new Set();
19
+
20
+ function canonicalTarget(path) {
21
+ let parent = resolve(path); const missing = [];
22
+ while (!existsSync(parent)) { missing.unshift(basename(parent)); parent = dirname(parent); }
23
+ return join(realpathSync(parent), ...missing);
24
+ }
25
+
26
+ export function workspaceMutationLockPath(path) {
27
+ const target = canonicalTarget(path);
28
+ return join(dirname(target), '.native-locks', `lock-${createHash('sha256').update(target).digest('hex')}.sqlite`);
29
+ }
30
+
31
+ export function acquireWorkspaceMutationLock(path, { timeoutMs = 10000 } = {}) {
32
+ if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 0 || timeoutMs > 120000) throw failure('workspace_lock_timeout_invalid');
33
+ const filename = workspaceMutationLockPath(path);
34
+ privateDirectory(dirname(filename), true);
35
+ // Open/create through the no-follow private file checks before SQLite opens
36
+ // it, including every auxiliary file SQLite might use in this directory.
37
+ const fd = openPrivate(filename, constants.O_RDWR | constants.O_CREAT); closeSync(fd);
38
+ for (const suffix of ['-journal', '-wal', '-shm']) privateFile(filename + suffix, true);
39
+ let db;
40
+ try {
41
+ db = new DatabaseSync(filename);
42
+ // BEGIN IMMEDIATE alone does not necessarily acquire a write lock on a
43
+ // brand-new empty database. Keep a single durable sentinel row so a
44
+ // second process reliably contends on the same SQLite write transaction.
45
+ db.exec(`PRAGMA busy_timeout=${timeoutMs}; CREATE TABLE IF NOT EXISTS workspace_lock (id INTEGER PRIMARY KEY CHECK (id = 1)); BEGIN IMMEDIATE; INSERT OR IGNORE INTO workspace_lock (id) VALUES (1);`);
46
+ activeDatabases.add(db);
47
+ } catch (error) {
48
+ try { db?.close(); } catch {}
49
+ if ([5, 6].includes(error.errcode)) throw failure('workspace_mutation_busy');
50
+ throw error;
51
+ }
52
+ let released = false;
53
+ return () => {
54
+ if (released) return;
55
+ released = true;
56
+ try { db.exec('ROLLBACK'); } finally { activeDatabases.delete(db); db.close(); }
57
+ };
58
+ }
59
+
60
+ /** Detect identity replacement as well as in-place mutation after permission
61
+ * waits or SQLite waits. assertCurrent is the Host's live ledger/fence check. */
62
+ export function workspaceMutationGuard(request, assertCurrent, checkRoot) {
63
+ if (assertCurrent != null && typeof assertCurrent !== 'function') throw failure('workspace_authority_invalid');
64
+ const fields = ['scope', 'sessionId', 'runId', 'turnId', 'buildId', 'parentRunId', 'taskChainId',
65
+ 'operationKey', 'executionFence', 'call', 'definition', 'interaction', 'contract', 'originalOperationKey', 'originalClaimedFence'];
66
+ const identity = () => canonical(Object.fromEntries(fields.filter(key => request[key] !== undefined).map(key => [key, request[key]])));
67
+ const before = identity(), signal = request.signal;
68
+ return () => {
69
+ checkRoot();
70
+ if (signal !== request.signal || before !== identity()) throw failure('workspace_execution_context_changed');
71
+ signal?.throwIfAborted();
72
+ if (assertCurrent && assertCurrent(request) !== true) throw failure('workspace_execution_authority_changed');
73
+ };
74
+ }
@@ -0,0 +1,17 @@
1
+ import { parentPort, workerData } from 'node:worker_threads';
2
+ import { derivePatchedContent } from '@kingsoftcloud/agentkit-harness-core/patch-apply';
3
+
4
+ try {
5
+ let bytes = 0;
6
+ const changes = workerData.map(item => {
7
+ const text = item.section.type === 'update' ? derivePatchedContent(item.text, item.section.chunks).content
8
+ : item.section.type === 'add' ? item.section.contents : '';
9
+ bytes += Buffer.byteLength(text);
10
+ if (Buffer.byteLength(text) > 2 * 1024 * 1024 || bytes > 16 * 1024 * 1024)
11
+ throw Object.assign(Error('Patched output exceeds the 2 MiB per-file or 16 MiB total limit'), { code: 'patch_budget_exceeded' });
12
+ return text;
13
+ });
14
+ parentPort.postMessage({ contract: 'zcode-patch/v1', changes });
15
+ } catch (error) {
16
+ parentPort.postMessage({ contract: 'zcode-patch/v1', error: String(error.message), code: error.code ?? 'apply_patch_hunk_not_found' });
17
+ }
@@ -0,0 +1,17 @@
1
+ import { Worker } from 'node:worker_threads';
2
+
3
+ /** Pure hunk verification. Publication remains in the owning workspace port. */
4
+ export async function prepareWorkspacePatch(input, { signal, timeoutMs = 2000 } = {}) {
5
+ signal?.throwIfAborted();
6
+ const worker = new Worker(new URL('./workspace-patch-worker.mjs', import.meta.url), { workerData: input, execArgv: [],
7
+ resourceLimits: { maxOldGenerationSizeMb: 128, maxYoungGenerationSizeMb: 16, stackSizeMb: 4 } });
8
+ return new Promise(resolve => {
9
+ let output, settled = false;
10
+ const finish = value => { if (settled) return; settled = true; clearTimeout(timer); signal?.removeEventListener('abort', stop); resolve(value); };
11
+ const stop = () => { worker.unref(); void worker.terminate().catch(() => {}); finish(null); };
12
+ const timer = setTimeout(stop, timeoutMs);
13
+ worker.once('message', value => { output = value; }); worker.once('error', stop);
14
+ worker.once('exit', code => finish(!signal?.aborted && code === 0 && output?.contract === 'zcode-patch/v1' ? output : null));
15
+ signal?.addEventListener('abort', stop, { once: true }); if (signal?.aborted) stop();
16
+ });
17
+ }
@@ -0,0 +1,225 @@
1
+ /**
2
+ * Path resolution, escape/symlink protection and atomic publication for the
3
+ * Native Node workspace tools. No external dependencies; Node >=22.19 builtins.
4
+ *
5
+ * The Python reference (ksadk.toolsets.workspace) resolves workspace paths with
6
+ * pathlib.Path.resolve(), which follows symlinks for the existing portion of a
7
+ * path and leaves a non-existent tail lexical. We reproduce that here so a
8
+ * symlink inside the workspace that points outside is rejected before any read
9
+ * or write, while a symlink that stays inside is followed.
10
+ */
11
+ import { createHash, randomBytes } from 'node:crypto';
12
+ import {
13
+ chmodSync, closeSync, existsSync, fsyncSync, linkSync, lstatSync, mkdirSync,
14
+ openSync, realpathSync, renameSync, unlinkSync, writeSync,
15
+ } from 'node:fs';
16
+ import { dirname, join, relative, resolve, sep } from 'node:path';
17
+
18
+ export const TWO_MB = 2 * 1024 * 1024;
19
+
20
+ /** sha256 hex digest of a string (utf-8) or Buffer. */
21
+ export function sha256(value) {
22
+ return createHash('sha256').update(value).digest('hex');
23
+ }
24
+
25
+ /** True when target is root or located beneath root (both absolute, posix-comparable). */
26
+ export function isInside(target, root) {
27
+ if (target === root) return true;
28
+ const rootWithSep = root.endsWith(sep) ? root : root + sep;
29
+ return target.startsWith(rootWithSep);
30
+ }
31
+
32
+ /**
33
+ * Resolve the existing portion of a path through symlinks, leaving a
34
+ * non-existent tail lexical. Mirrors pathlib.Path.resolve(strict=False).
35
+ */
36
+ export function realpathExisting(p) {
37
+ if (existsSync(p)) return realpathSync(p);
38
+ const tail = [];
39
+ let head = p;
40
+ // Walk up to the longest existing ancestor; resolve it, then re-append.
41
+ while (!existsSync(head)) {
42
+ const base = dirname(head);
43
+ if (base === head) break; // reached filesystem root
44
+ tail.unshift(head.slice(base.length + 1) || head.slice(base.length));
45
+ head = base;
46
+ }
47
+ const realHead = existsSync(head) ? realpathSync(head) : head;
48
+ return tail.length ? join(realHead, ...tail) : realHead;
49
+ }
50
+
51
+ /**
52
+ * Resolve a workspace-relative path and enforce it stays inside the root.
53
+ * Leading slashes and backslashes are normalized the way the Python port does:
54
+ * backslashes become '/', a leading '/' is stripped, empty becomes '.'.
55
+ * Throws a WorkspacePathError when the resolved target escapes the root.
56
+ */
57
+ export function resolveWorkspacePath(root, rootReal, relativePath) {
58
+ const raw = String(relativePath ?? '').trim().replace(/\\/g, '/').replace(/^\/+/, '') || '.';
59
+ const lexical = resolve(rootReal, raw);
60
+ if (!isInside(lexical, rootReal)) {
61
+ throw new WorkspacePathError('workspace path must stay inside the workspace root');
62
+ }
63
+ const real = realpathExisting(lexical);
64
+ if (!isInside(real, rootReal)) {
65
+ throw new WorkspacePathError('workspace path must stay inside the workspace root');
66
+ }
67
+ return real;
68
+ }
69
+
70
+ /** Workspace-relative posix path for a resolved absolute target. */
71
+ export function workspaceRelative(rootReal, target) {
72
+ const rel = relative(rootReal, target);
73
+ return rel.split(sep).join('/');
74
+ }
75
+
76
+ export class WorkspacePathError extends Error {
77
+ constructor(message) {
78
+ super(message);
79
+ this.name = 'WorkspacePathError';
80
+ this.code = 'workspace_path_escape';
81
+ }
82
+ }
83
+
84
+ /**
85
+ * Publish a complete sibling file atomically. Never truncate the target in
86
+ * place: write to a fresh temp sibling, fsync, then link (create-only) or
87
+ * rename (overwrite). Preserves an existing file's mode. A failed publication
88
+ * leaves the original content and read-state unchanged.
89
+ *
90
+ * Mirrors ksadk.toolsets.workspace_mutation.publish_workspace_bytes.
91
+ */
92
+ export function publishWorkspaceBytes(target, payload, { createOnly = false } = {}) {
93
+ const parent = dirname(target);
94
+ mkdirSync(parent, { recursive: true });
95
+ let mode = null;
96
+ try {
97
+ const info = lstatSync(target);
98
+ if (!info.isFile()) throw new WorkspaceMutationError('workspace mutation requires a regular file');
99
+ mode = info.mode & 0o777;
100
+ } catch (error) {
101
+ if (error instanceof WorkspaceMutationError) throw error;
102
+ if (error.code !== 'ENOENT') throw error;
103
+ }
104
+ const temporary = join(parent, `.${target.split(sep).pop()}.native-${randomHex(12)}`);
105
+ const fd = openSync(temporary, 'wx', 0o666);
106
+ try {
107
+ writeAll(fd, payload);
108
+ closeSync(fd);
109
+ if (mode !== null) applyMode(temporary, mode);
110
+ if (createOnly) {
111
+ linkSync(temporary, target); // fails if target now exists (race-safe create)
112
+ } else {
113
+ renameSync(temporary, target); // atomic replace of the directory entry
114
+ }
115
+ } catch (error) {
116
+ // writeAll/close/chmod/link/rename failed: ensure the fd is closed and the
117
+ // temp sibling is removed. The original target is never touched on this
118
+ // path (link/rename are the only steps that affect it), so its content and
119
+ // the read-state the caller recorded remain intact.
120
+ try { closeSync(fd); } catch { /* already closed or never opened */ }
121
+ safeUnlink(temporary);
122
+ throw error;
123
+ } finally {
124
+ // A successful create-only publication leaves the temporary hard link in
125
+ // place until it is unlinked here. Rename has already removed its name.
126
+ safeUnlink(temporary);
127
+ }
128
+ }
129
+
130
+ function writeAll(fd, payload) {
131
+ // The caller owns closing fd on success; on failure the caller's catch
132
+ // closes it. fsync before returning so the bytes are durable before the
133
+ // atomic link/rename.
134
+ const buffer = Buffer.isBuffer(payload) ? payload : Buffer.from(payload, 'utf-8');
135
+ let offset = 0;
136
+ while (offset < buffer.length) {
137
+ const written = writeSync(fd, buffer, offset);
138
+ if (written <= 0) throw new WorkspaceMutationError('workspace write stalled');
139
+ offset += written;
140
+ }
141
+ fsyncSync(fd);
142
+ }
143
+
144
+ function applyMode(target, mode) {
145
+ try {
146
+ // chmod the temp file to match the pre-existing target's permission bits.
147
+ chmodSync(target, mode);
148
+ } catch {
149
+ /* best-effort; a failed chmod must not abort an otherwise valid publish */
150
+ }
151
+ }
152
+
153
+ function safeUnlink(p) {
154
+ try { unlinkSync(p); } catch { /* already gone */ }
155
+ }
156
+
157
+ export class WorkspaceMutationError extends Error {
158
+ constructor(message, code = 'workspace_mutation_failed') {
159
+ super(message);
160
+ this.name = 'WorkspaceMutationError';
161
+ this.code = code;
162
+ }
163
+ }
164
+
165
+ function randomHex(bytes) {
166
+ return randomBytes(bytes).toString('hex');
167
+ }
168
+
169
+ /** fnmatch-style glob test (case-sensitive, matching POSIX normcase identity). */
170
+ export function fnmatch(name, pattern) {
171
+ return globToRegex(pattern).test(name);
172
+ }
173
+
174
+ export function globToRegex(pattern) {
175
+ let out = '';
176
+ for (let i = 0; i < pattern.length; i++) {
177
+ const c = pattern[i];
178
+ if (c === '*') {
179
+ out += '.*';
180
+ } else if (c === '?') {
181
+ out += '.';
182
+ } else if (c === '[') {
183
+ const parsed = parseCharClass(pattern, i);
184
+ if (parsed) {
185
+ out += parsed.regex;
186
+ i = parsed.next - 1;
187
+ } else {
188
+ out += '\\[';
189
+ }
190
+ } else if ('^$.+(){}|\\'.includes(c)) {
191
+ out += '\\' + c;
192
+ } else {
193
+ out += c;
194
+ }
195
+ }
196
+ return new RegExp('^' + out + '$');
197
+ }
198
+
199
+ function parseCharClass(pattern, start) {
200
+ let j = start + 1;
201
+ let negate = false;
202
+ if (j < pattern.length && (pattern[j] === '!' || pattern[j] === '^')) {
203
+ negate = true;
204
+ j++;
205
+ }
206
+ let cls = '';
207
+ if (j < pattern.length && pattern[j] === ']') {
208
+ cls += '\\]';
209
+ j++;
210
+ }
211
+ while (j < pattern.length && pattern[j] !== ']') {
212
+ const ch = pattern[j];
213
+ if (ch === '\\') cls += '\\\\';
214
+ else if ('^$.+*?(){}|['.includes(ch)) cls += '\\' + ch;
215
+ else cls += ch;
216
+ j++;
217
+ }
218
+ if (j >= pattern.length) return null; // no closing bracket; treat '[' literally
219
+ return { regex: '[' + (negate ? '^' : '') + cls + ']', next: j + 1 };
220
+ }
221
+
222
+ /** Escape a literal string for use in a RegExp. */
223
+ export function escapeRegex(value) {
224
+ return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
225
+ }
@@ -0,0 +1,272 @@
1
+ /**
2
+ * Per-session persisted execution state for the Native Node workspace tools.
3
+ *
4
+ * Two pieces of state are owned by this port and must survive an instance or
5
+ * process restart, keyed by sessionId:
6
+ * - read-state: the last observed file revision per relative path, used to
7
+ * gate edits/overwrites (read-after-modify and stale-read rejection).
8
+ * - pending approvals: outstanding approval_required interactions, used to
9
+ * validate the interactionId/callId binding on an approval reply.
10
+ *
11
+ * Credentials are never stored. Pending approvals keep only an arguments
12
+ * digest, never the tool arguments themselves. The store is single-session:
13
+ * one port instance owns one sessionId; cross-session isolation comes from a
14
+ * separate per-session directory under stateDir.
15
+ *
16
+ * sessionId is an untrusted caller-supplied identifier. It is never joined
17
+ * into the filesystem path verbatim, because a value like "../x" or "a/b"
18
+ * would escape stateDir or collide with another session's directory. The
19
+ * on-disk directory is keyed by a sha256 hex digest of the normalized id, which
20
+ * contains no path separators; the original id is stored inside the
21
+ * JSON files for audit/inspection only.
22
+ */
23
+ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
24
+ import { isAbsolute, join } from 'node:path';
25
+ import { createHash, randomUUID } from 'node:crypto';
26
+
27
+ const READ_STATE_VERSION = 'native-workspace-read-state/v1';
28
+ const PENDING_VERSION = 'native-workspace-pending-approvals/v1';
29
+ const PENDING_TTL_MS = 10 * 60 * 1000;
30
+ const HEX = /^[0-9a-f]{64}$/;
31
+
32
+ /** A recorded observed revision of a workspace file. mtimeNs is a BigInt. */
33
+ export function makeReadState(fields) {
34
+ return {
35
+ path: fields.path,
36
+ mtimeNs: BigInt(fields.mtimeNs),
37
+ sizeBytes: Number(fields.sizeBytes),
38
+ startLine: Number(fields.startLine),
39
+ endLine: Number(fields.endLine),
40
+ totalLines: Number(fields.totalLines),
41
+ partial: Boolean(fields.partial),
42
+ canonicalPath: fields.canonicalPath,
43
+ contentSha256: fields.contentSha256,
44
+ };
45
+ }
46
+
47
+ /** Serialize a read state for JSON storage (BigInt mtimeNs -> string). */
48
+ export function serializeReadState(state) {
49
+ return {
50
+ path: state.path,
51
+ mtimeNs: state.mtimeNs.toString(),
52
+ sizeBytes: state.sizeBytes,
53
+ startLine: state.startLine,
54
+ endLine: state.endLine,
55
+ totalLines: state.totalLines,
56
+ partial: state.partial,
57
+ canonicalPath: state.canonicalPath,
58
+ contentSha256: state.contentSha256,
59
+ };
60
+ }
61
+
62
+ /** Deserialize a JSON-stored read state back to a BigInt-mtimeNs object. */
63
+ export function deserializeReadState(raw) {
64
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)
65
+ || typeof raw.path !== 'string' || !raw.path || isAbsolute(raw.path) || raw.path.includes('\0')
66
+ || raw.path.split('/').includes('..') || typeof raw.canonicalPath !== 'string' || !isAbsolute(raw.canonicalPath)
67
+ || typeof raw.contentSha256 !== 'string' || !HEX.test(raw.contentSha256)
68
+ || typeof raw.mtimeNs !== 'string' || !/^-?\d+$/.test(raw.mtimeNs)
69
+ || typeof raw.partial !== 'boolean') return null;
70
+ if (!['sizeBytes', 'startLine', 'endLine', 'totalLines'].every(key => Number.isSafeInteger(raw[key]))
71
+ || raw.sizeBytes < 0 || raw.startLine < 1 || raw.endLine < 0 || raw.totalLines < 0
72
+ || raw.endLine > raw.totalLines
73
+ || (raw.totalLines > 0 && (raw.startLine > raw.endLine || raw.endLine === 0))
74
+ || (!raw.partial && (raw.startLine !== 1 || raw.endLine !== raw.totalLines))) return null;
75
+ return makeReadState(raw);
76
+ }
77
+
78
+ export class WorkspaceStateStore {
79
+ constructor(stateDir, sessionId) {
80
+ this.sessionId = normalizeSessionId(sessionId);
81
+ // The on-disk directory is a sha256 hex of the normalized id, so a caller
82
+ // cannot traverse out of stateDir ("../x") or collide with another session
83
+ // by embedding a path separator. The original id is kept in the JSON files
84
+ // for inspection but never used as a filesystem path component.
85
+ this.sessionKey = sessionKeyFor(this.sessionId);
86
+ this.dir = join(stateDir, this.sessionKey);
87
+ this._readStatePath = join(this.dir, 'read-state.json');
88
+ this._pendingPath = join(this.dir, 'pending-approvals.json');
89
+ /** Map<relativePath, readState> (BigInt mtimeNs, in memory). */
90
+ this._readState = new Map();
91
+ /** Map<interactionId, pending> (in memory). */
92
+ this._pending = new Map();
93
+ this._loaded = false;
94
+ }
95
+
96
+ _ensureLoaded() {
97
+ if (this._loaded) return;
98
+ this._loaded = true;
99
+ this._readState = this._loadReadState();
100
+ this._pending = this._loadPending();
101
+ }
102
+
103
+ _loadReadState() {
104
+ try {
105
+ const raw = JSON.parse(readFileSync(this._readStatePath, 'utf-8'));
106
+ if (raw && raw.version === READ_STATE_VERSION && raw.sessionId === this.sessionId
107
+ && raw.entries && typeof raw.entries === 'object' && !Array.isArray(raw.entries)) {
108
+ this._conversationBranch = raw.conversationBranch;
109
+ const map = new Map();
110
+ for (const [key, value] of Object.entries(raw.entries)) {
111
+ const state = deserializeReadState(value);
112
+ // Drop entries missing the fields validateReadState needs; an edit
113
+ // against a dropped entry is correctly re-rejected as file_not_read
114
+ // rather than crashing the restart on a BigInt/NaN coercion.
115
+ if (state && state.path === key) {
116
+ map.set(key, state);
117
+ }
118
+ }
119
+ return map;
120
+ }
121
+ } catch { /* missing or corrupt: start clean */ }
122
+ return new Map();
123
+ }
124
+
125
+ _loadPending() {
126
+ try {
127
+ const raw = JSON.parse(readFileSync(this._pendingPath, 'utf-8'));
128
+ if (raw && raw.version === PENDING_VERSION && raw.sessionId === this.sessionId
129
+ && raw.entries && typeof raw.entries === 'object' && !Array.isArray(raw.entries)) {
130
+ return new Map(Object.entries(raw.entries).filter(([id, pending]) => pending && pending.interactionId === id
131
+ && typeof pending.callId === 'string' && pending.callId.trim() && typeof pending.name === 'string' && pending.name.trim()
132
+ && typeof pending.argumentsDigest === 'string' && HEX.test(pending.argumentsDigest)
133
+ && Number.isSafeInteger(pending.createdAt) && pending.createdAt >= 0
134
+ && Number.isSafeInteger(pending.expiresAt) && pending.expiresAt >= pending.createdAt));
135
+ }
136
+ } catch { /* missing or corrupt: start clean */ }
137
+ return new Map();
138
+ }
139
+
140
+ _persistReadState() {
141
+ this._ensureDir();
142
+ const payload = {
143
+ version: READ_STATE_VERSION,
144
+ sessionId: this.sessionId,
145
+ ...(this._conversationBranch ? { conversationBranch: this._conversationBranch } : {}),
146
+ entries: Object.fromEntries(
147
+ [...this._readState.values()].map(serializeReadState).map((s) => [s.path, s]),
148
+ ),
149
+ };
150
+ atomicWriteJson(this._readStatePath, payload);
151
+ }
152
+
153
+ _persistPending() {
154
+ this._ensureDir();
155
+ const payload = {
156
+ version: PENDING_VERSION,
157
+ sessionId: this.sessionId,
158
+ entries: Object.fromEntries(this._pending),
159
+ };
160
+ atomicWriteJson(this._pendingPath, payload);
161
+ }
162
+
163
+ _ensureDir() {
164
+ mkdirSync(this.dir, { recursive: true });
165
+ }
166
+
167
+ getReadState(relativePath) {
168
+ this._ensureLoaded();
169
+ return this._readState.get(relativePath);
170
+ }
171
+
172
+ recordReadState(state) {
173
+ this._ensureLoaded();
174
+ this._readState.set(state.path, state);
175
+ this._persistReadState();
176
+ }
177
+
178
+ clearReadState() {
179
+ this._ensureLoaded();
180
+ this._readState.clear();
181
+ this._persistReadState();
182
+ }
183
+
184
+ adoptConversationBranch(branch, restore = () => []) {
185
+ if (!branch) return;
186
+ if (!Number.isSafeInteger(branch.generation) || branch.generation < 1 || typeof branch.commandId !== 'string' || !branch.commandId.trim())
187
+ throw Object.assign(Error('workspace_read_branch_invalid'), { code: 'workspace_read_branch_invalid' });
188
+ this._ensureLoaded();
189
+ if (this._conversationBranch?.generation === branch.generation && this._conversationBranch.commandId === branch.commandId) return;
190
+ if (this._conversationBranch?.generation >= branch.generation)
191
+ throw Object.assign(Error('workspace_read_branch_conflict'), { code: 'workspace_read_branch_conflict' });
192
+ // Never let a read on the removed branch authorize an edit after rewind.
193
+ // Fresh reads in this generation remain valid across later turns/restarts.
194
+ const retained = new Map();
195
+ for (const raw of restore()) {
196
+ const state = deserializeReadState(raw);
197
+ if (state && !state.partial) retained.set(state.path, state);
198
+ }
199
+ const previousReads = this._readState, previousBranch = this._conversationBranch;
200
+ this._readState = retained; this._conversationBranch = { ...branch };
201
+ try { this._persistReadState(); }
202
+ catch (error) { this._readState = previousReads; this._conversationBranch = previousBranch; throw error; }
203
+ }
204
+
205
+ addPending({ callId, name, argumentsDigest, ttlMs = PENDING_TTL_MS }) {
206
+ this._ensureLoaded();
207
+ const now = Date.now();
208
+ const interactionId = randomUUID();
209
+ const pending = {
210
+ interactionId,
211
+ callId,
212
+ name,
213
+ argumentsDigest,
214
+ createdAt: now,
215
+ expiresAt: now + ttlMs,
216
+ };
217
+ this._pending.set(interactionId, pending);
218
+ this._persistPending();
219
+ return pending;
220
+ }
221
+
222
+ getPending(interactionId) {
223
+ this._ensureLoaded();
224
+ const pending = this._pending.get(interactionId);
225
+ if (!pending) return undefined;
226
+ if (pending.expiresAt < Date.now()) {
227
+ this._pending.delete(interactionId);
228
+ this._persistPending();
229
+ return undefined;
230
+ }
231
+ return pending;
232
+ }
233
+
234
+ consumePending(interactionId) {
235
+ this._ensureLoaded();
236
+ const pending = this._pending.get(interactionId);
237
+ if (!pending) return undefined;
238
+ this._pending.delete(interactionId);
239
+ this._persistPending();
240
+ if (pending.expiresAt < Date.now()) return undefined;
241
+ return pending;
242
+ }
243
+ }
244
+
245
+ /** Write JSON atomically (temp sibling + rename) to avoid torn reads on restart. */
246
+ export function atomicWriteJson(dest, value) {
247
+ const dir = join(dest, '..');
248
+ mkdirSync(dir, { recursive: true });
249
+ const tmp = join(dir, `.${dest.split('/').pop()}.tmp-${process.pid}-${Math.random().toString(36).slice(2)}`);
250
+ writeFileSync(tmp, JSON.stringify(value), 'utf-8');
251
+ renameSync(tmp, dest);
252
+ }
253
+
254
+ /**
255
+ * The sha256 directory key contains no path separators, so identifiers cannot
256
+ * traverse out of stateDir. Stored records also carry the original identity.
257
+ */
258
+ export function sessionKeyFor(sessionId) {
259
+ const digest = createHash('sha256').update(normalizeSessionId(sessionId), 'utf-8').digest('hex');
260
+ return HEX.test(digest) ? digest : 'default';
261
+ }
262
+
263
+ function normalizeSessionId(sessionId) {
264
+ if (sessionId === undefined) return 'default';
265
+ if (typeof sessionId !== 'string' || !sessionId.trim()) throw new TypeError('workspace sessionId must be a nonempty string');
266
+ return sessionId;
267
+ }
268
+
269
+ /** sha256 hex digest of a JSON-serializable value (stable key order). */
270
+ export function argumentsDigest(value) {
271
+ return createHash('sha256').update(JSON.stringify(value ?? null)).digest('hex');
272
+ }