@tencent-ai/agent-harness 0.1.0 → 0.1.1

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 (41) hide show
  1. package/README.md +19 -7
  2. package/bin/native/darwin-arm64/agent-harness-process-snapshot.node +0 -0
  3. package/bin/native/darwin-x64/agent-harness-process-snapshot.node +0 -0
  4. package/cli/dist/codebuddy-headless.js +1 -1
  5. package/cli/dist/codebuddy-server.js +1 -1
  6. package/cli/product.cloudhosted.json +2 -2
  7. package/cli/product.internal.json +2 -2
  8. package/cli/product.ioa.json +2 -2
  9. package/cli/product.json +2 -2
  10. package/cli/product.selfhosted.json +2 -2
  11. package/docs/authentication-guide.md +4 -2
  12. package/executor/lib/capsule/entry.mjs +2 -2
  13. package/lib/integration/executor-supervisor.d.ts +4 -1
  14. package/lib/integration/executor-supervisor.js +7 -3
  15. package/lib/integration/stdio-guardian-child.js +11 -21
  16. package/lib/integration/stdio-guardian.d.ts +2 -1
  17. package/lib/integration/stdio-guardian.js +7 -5
  18. package/lib/runtime/agent-harness.d.ts +3 -1
  19. package/lib/runtime/agent-harness.js +12 -3
  20. package/lib/runtime/codebuddy-code.d.ts +5 -1
  21. package/lib/runtime/codebuddy-code.js +13 -3
  22. package/lib/runtime/codebuddy-prewarm-pool.d.ts +2 -0
  23. package/lib/runtime/codebuddy-prewarm-pool.js +9 -1
  24. package/lib/runtime/codebuddy-process.d.ts +2 -1
  25. package/lib/runtime/codebuddy-process.js +5 -22
  26. package/lib/runtime/codebuddy-resource-management.d.ts +2 -0
  27. package/lib/runtime/codebuddy-resource-management.js +13 -1
  28. package/lib/runtime/codebuddy-resource-process.d.ts +5 -2
  29. package/lib/runtime/codebuddy-resource-process.js +28 -8
  30. package/lib/runtime/codebuddy-v2-private-child.js +14 -1
  31. package/lib/runtime/node-runtime.d.ts +35 -0
  32. package/lib/runtime/node-runtime.js +46 -0
  33. package/lib/runtime/posix-contained-process.d.ts +3 -0
  34. package/lib/runtime/posix-contained-process.js +5 -3
  35. package/lib/runtime/posix-process-snapshot.d.ts +28 -0
  36. package/lib/runtime/posix-process-snapshot.js +183 -0
  37. package/lib/runtime/process-tree.d.ts +6 -8
  38. package/lib/runtime/process-tree.js +9 -37
  39. package/lib/transport/network.d.ts +7 -1
  40. package/lib/transport/network.js +15 -1
  41. package/package.json +5 -3
@@ -40,6 +40,7 @@ const process_runtime_context_js_1 = require("../effect-runtime/process-runtime-
40
40
  const async_js_1 = require("../internal/async.js");
41
41
  const codebuddy_prewarm_pool_js_1 = require("./codebuddy-prewarm-pool.js");
42
42
  const codebuddy_v2_child_js_1 = require("./codebuddy-v2-child.js");
43
+ const node_runtime_js_1 = require("./node-runtime.js");
43
44
  const process_containment_js_1 = require("./process-containment.js");
44
45
  const STARTUP_ABORT_MESSAGE = 'Private CodeBuddy v2 startup aborted';
45
46
  function requestOptions(options) {
@@ -275,13 +276,24 @@ async function createCodeBuddyV2PrivateChildPrewarmRuntime(options) {
275
276
  if (!binPath || !path.isAbsolute(binPath)) {
276
277
  throw new Error('Private CodeBuddy prewarm requires an absolute CLI artifact');
277
278
  }
278
- const nodeExecutable = defaults.nodeExecutable ?? process.execPath;
279
+ if (defaults.nodeExecutable !== undefined
280
+ && defaults.nodeRuntime !== undefined
281
+ && defaults.nodeExecutable !== defaults.nodeRuntime.executable) {
282
+ throw new Error('CodeBuddy Node runtime executable is ambiguous');
283
+ }
284
+ const nodeRuntime = defaults.nodeRuntime ?? (0, node_runtime_js_1.resolveNodeRuntimeLaunch)({
285
+ ...(defaults.nodeExecutable === undefined
286
+ ? {}
287
+ : { executable: defaults.nodeExecutable }),
288
+ });
289
+ const nodeExecutable = nodeRuntime.executable;
279
290
  const key = (0, codebuddy_prewarm_pool_js_1.createCodeBuddyPrewarmKey)({
280
291
  cliArtifactPath: binPath,
281
292
  cliVersion,
282
293
  cwd,
283
294
  env: defaults.env,
284
295
  nodeExecutablePath: nodeExecutable,
296
+ nodeRuntime,
285
297
  preserveManagedAuthenticationEnvironment: defaults.preserveManagedAuthenticationEnvironment,
286
298
  processEnv: defaults.processEnv,
287
299
  productLaunch: primeOptions.productLaunch,
@@ -295,6 +307,7 @@ async function createCodeBuddyV2PrivateChildPrewarmRuntime(options) {
295
307
  dependencies: defaults.dependencies,
296
308
  env: defaults.env,
297
309
  nodeExecutable,
310
+ nodeRuntime,
298
311
  posixContainmentMode: defaults.posixContainmentMode,
299
312
  preserveManagedAuthenticationEnvironment: defaults.preserveManagedAuthenticationEnvironment,
300
313
  processEnv: defaults.processEnv,
@@ -0,0 +1,35 @@
1
+ export declare const ELECTRON_RUN_AS_NODE_ENV = "ELECTRON_RUN_AS_NODE";
2
+ export interface NodeRuntimeEnvironment {
3
+ readonly ELECTRON_RUN_AS_NODE?: '1';
4
+ }
5
+ export interface NodeRuntimeLaunch {
6
+ /** Executable which evaluates the JavaScript entry passed as argv[1]. */
7
+ readonly executable: string;
8
+ /** Runtime-owned environment overlay required by that executable. */
9
+ readonly environment: Readonly<NodeRuntimeEnvironment>;
10
+ }
11
+ export interface ResolveNodeRuntimeLaunchOptions {
12
+ /** Defaults to the executable evaluating the current Harness process. */
13
+ readonly executable?: string;
14
+ /** Deterministic test seam; production uses `process.execPath`. */
15
+ readonly currentExecutable?: string;
16
+ /** Deterministic test seam; production uses `process.versions.electron`. */
17
+ readonly electronVersion?: string;
18
+ /** Explicit contract for a non-current Electron executable. */
19
+ readonly electronRunAsNode?: boolean;
20
+ }
21
+ /**
22
+ * Resolves a coherent JavaScript runtime launch contract.
23
+ *
24
+ * Electron keeps its Electron identity while running as Node. A child launch
25
+ * therefore needs both `process.execPath` and `ELECTRON_RUN_AS_NODE=1`; the
26
+ * executable alone is not a Node runtime. Ordinary custom executables are
27
+ * treated as Node unless their owner explicitly selects Electron-as-Node.
28
+ */
29
+ export declare function resolveNodeRuntimeLaunch({ currentExecutable, electronRunAsNode, electronVersion, executable, }?: ResolveNodeRuntimeLaunchOptions): NodeRuntimeLaunch;
30
+ /**
31
+ * Applies the runtime-owned overlay after removing ambient spellings of the
32
+ * same keys. Environment names are compared case-insensitively so Windows
33
+ * cannot retain a conflicting `electron_run_as_node` value.
34
+ */
35
+ export declare function applyNodeRuntimeEnvironment(base: NodeJS.ProcessEnv, runtime?: NodeRuntimeLaunch): NodeJS.ProcessEnv;
@@ -0,0 +1,46 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.ELECTRON_RUN_AS_NODE_ENV = void 0;
4
+ exports.resolveNodeRuntimeLaunch = resolveNodeRuntimeLaunch;
5
+ exports.applyNodeRuntimeEnvironment = applyNodeRuntimeEnvironment;
6
+ exports.ELECTRON_RUN_AS_NODE_ENV = 'ELECTRON_RUN_AS_NODE';
7
+ /**
8
+ * Resolves a coherent JavaScript runtime launch contract.
9
+ *
10
+ * Electron keeps its Electron identity while running as Node. A child launch
11
+ * therefore needs both `process.execPath` and `ELECTRON_RUN_AS_NODE=1`; the
12
+ * executable alone is not a Node runtime. Ordinary custom executables are
13
+ * treated as Node unless their owner explicitly selects Electron-as-Node.
14
+ */
15
+ function resolveNodeRuntimeLaunch({ currentExecutable = process.execPath, electronRunAsNode, electronVersion = process.versions.electron, executable = currentExecutable, } = {}) {
16
+ const useElectronNode = electronRunAsNode
17
+ ?? (executable === currentExecutable
18
+ && typeof electronVersion === 'string'
19
+ && electronVersion.length > 0);
20
+ return Object.freeze({
21
+ executable,
22
+ environment: Object.freeze(useElectronNode
23
+ ? { [exports.ELECTRON_RUN_AS_NODE_ENV]: '1' }
24
+ : {}),
25
+ });
26
+ }
27
+ /**
28
+ * Applies the runtime-owned overlay after removing ambient spellings of the
29
+ * same keys. Environment names are compared case-insensitively so Windows
30
+ * cannot retain a conflicting `electron_run_as_node` value.
31
+ */
32
+ function applyNodeRuntimeEnvironment(base, runtime = resolveNodeRuntimeLaunch()) {
33
+ if (Object.keys(runtime.environment).some(key => key !== exports.ELECTRON_RUN_AS_NODE_ENV)
34
+ || (runtime.environment.ELECTRON_RUN_AS_NODE !== undefined
35
+ && runtime.environment.ELECTRON_RUN_AS_NODE !== '1')) {
36
+ throw new Error('Node runtime environment contract is invalid');
37
+ }
38
+ const ownedKeys = new Set([exports.ELECTRON_RUN_AS_NODE_ENV].map(key => key.toUpperCase()));
39
+ const environment = Object.fromEntries(Object.entries(base).filter(([key]) => !ownedKeys.has(key.toUpperCase())));
40
+ return {
41
+ ...environment,
42
+ ...(runtime.environment.ELECTRON_RUN_AS_NODE === '1'
43
+ ? { [exports.ELECTRON_RUN_AS_NODE_ENV]: '1' }
44
+ : {}),
45
+ };
46
+ }
@@ -1,6 +1,7 @@
1
1
  import { type ChildProcess, type SpawnOptions } from 'node:child_process';
2
2
  import { type PosixStdioMcpGuardian, type PosixStdioMcpGuardianOptions } from '../integration/stdio-guardian.js';
3
3
  import type { CodeBuddyCodeSpawnSpec } from './codebuddy-code.js';
4
+ import { type NodeRuntimeLaunch } from './node-runtime.js';
4
5
  export interface ContainedTargetExit {
5
6
  readonly code: number | null;
6
7
  readonly signal: NodeJS.Signals | null;
@@ -68,6 +69,8 @@ export interface AcquirePosixContainedProcessOptions {
68
69
  readonly anchorEntryPath?: string;
69
70
  readonly containmentMode?: 'process-group' | 'process-tree';
70
71
  readonly killTimeoutMs?: number;
72
+ /** @internal JavaScript runtime used for the private anchor child. */
73
+ readonly nodeRuntime?: NodeRuntimeLaunch;
71
74
  readonly onAnchorSpawned?: (anchor: ChildProcess) => void;
72
75
  readonly onTargetStarted?: (anchorPid: number, targetPid: number) => void;
73
76
  readonly platform?: NodeJS.Platform;
@@ -13,6 +13,7 @@ const node_path_1 = __importDefault(require("node:path"));
13
13
  const stdio_guardian_js_1 = require("../integration/stdio-guardian.js");
14
14
  const async_js_1 = require("../internal/async.js");
15
15
  const errors_js_1 = require("../internal/errors.js");
16
+ const node_runtime_js_1 = require("./node-runtime.js");
16
17
  const posix_process_group_anchor_protocol_js_1 = require("./posix-process-group-anchor-protocol.js");
17
18
  const DEFAULT_KILL_TIMEOUT_MS = 3_000;
18
19
  const DEFAULT_POLL_INTERVAL_MS = 50;
@@ -300,15 +301,16 @@ async function acquirePosixContainedProcess(input) {
300
301
  const attachGuardian = input.attachGuardian
301
302
  ?? stdio_guardian_js_1.attachPosixStdioMcpGuardian;
302
303
  let child;
304
+ const nodeRuntime = input.nodeRuntime ?? (0, node_runtime_js_1.resolveNodeRuntimeLaunch)();
303
305
  try {
304
- child = spawnAnchor(process.execPath, [anchorEntryPath], {
306
+ child = spawnAnchor(nodeRuntime.executable, [anchorEntryPath], {
305
307
  cwd: node_path_1.default.dirname(anchorEntryPath),
306
308
  detached: true,
307
309
  // Target secrets/configuration cross private IPC only. The anchor
308
310
  // process itself receives no ambient Harness or user environment.
309
- env: {
311
+ env: (0, node_runtime_js_1.applyNodeRuntimeEnvironment)({
310
312
  AGENT_HARNESS_PRIVATE_PARENT_DEATH_OWNER: '1',
311
- },
313
+ }, nodeRuntime),
312
314
  stdio: ['pipe', 'pipe', 'pipe', 'ipc'],
313
315
  windowsHide: true,
314
316
  });
@@ -0,0 +1,28 @@
1
+ import { type PosixProcessRecord } from './process-tree.js';
2
+ interface DarwinProcessSnapshotBinding {
3
+ captureOwnedProcessIds(rootPid: number, ownershipId: number, excludedPid: number, timeoutMs: number): unknown;
4
+ }
5
+ export interface PosixProcessSnapshotOptions {
6
+ /** Process which must never be classified as child-owned. */
7
+ readonly excludedPid?: number;
8
+ /** Detached process group/session which proves direct ownership. */
9
+ readonly ownershipId?: number;
10
+ /** Test seam for the in-process Darwin libproc binding. */
11
+ readonly darwinBinding?: DarwinProcessSnapshotBinding;
12
+ /** Test seam for resolving an installed package instead of this package. */
13
+ readonly packageRoot?: string;
14
+ /** Test seam for a synthetic procfs. */
15
+ readonly procRoot?: string;
16
+ readonly readDirectory?: (directory: string) => Promise<readonly string[]>;
17
+ readonly readTextFile?: (file: string) => Promise<string>;
18
+ readonly timeoutMs?: number;
19
+ }
20
+ /** Parses the stable ownership fields from one Linux `/proc/<pid>/stat`. */
21
+ export declare function parseLinuxProcStat(expectedPid: number, stat: string): PosixProcessRecord;
22
+ export declare function resolveDarwinProcessSnapshotAddonPath(arch?: NodeJS.Architecture, packageRoot?: string): string;
23
+ /**
24
+ * Captures a containment-owned POSIX process tree without launching a global
25
+ * process utility. Any unsupported platform or incomplete snapshot rejects.
26
+ */
27
+ export declare function capturePosixOwnedProcessIds(rootPid: number, platform: NodeJS.Platform, options?: PosixProcessSnapshotOptions): Promise<readonly number[]>;
28
+ export {};
@@ -0,0 +1,183 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.parseLinuxProcStat = parseLinuxProcStat;
7
+ exports.resolveDarwinProcessSnapshotAddonPath = resolveDarwinProcessSnapshotAddonPath;
8
+ exports.capturePosixOwnedProcessIds = capturePosixOwnedProcessIds;
9
+ const promises_1 = require("node:fs/promises");
10
+ const node_module_1 = require("node:module");
11
+ const node_path_1 = __importDefault(require("node:path"));
12
+ const process_tree_js_1 = require("./process-tree.js");
13
+ const MAX_PROCESS_COUNT = 65_536;
14
+ const PROC_READ_CONCURRENCY = 32;
15
+ const loadNativeModule = (0, node_module_1.createRequire)(__filename);
16
+ function assertPositivePid(pid, name) {
17
+ if (!Number.isSafeInteger(pid) || pid <= 0 || pid > 0x7fff_ffff) {
18
+ throw new TypeError(`${name} must be a positive 32-bit PID`);
19
+ }
20
+ }
21
+ function errorCode(error) {
22
+ return error instanceof Error && 'code' in error
23
+ ? String(error.code)
24
+ : undefined;
25
+ }
26
+ function isVanishedProcEntry(error) {
27
+ const code = errorCode(error);
28
+ return code === 'ENOENT' || code === 'ESRCH';
29
+ }
30
+ function assertWithinDeadline(deadlineAt) {
31
+ if (Date.now() >= deadlineAt) {
32
+ throw new Error('POSIX process snapshot deadline expired');
33
+ }
34
+ }
35
+ async function settleWithin(operation, timeoutMs) {
36
+ if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
37
+ operation.catch(() => undefined);
38
+ throw new Error('POSIX process snapshot deadline expired');
39
+ }
40
+ let timer;
41
+ try {
42
+ return await Promise.race([
43
+ operation,
44
+ new Promise((_resolve, reject) => {
45
+ timer = setTimeout(() => {
46
+ reject(new Error('POSIX process snapshot deadline expired'));
47
+ }, timeoutMs);
48
+ }),
49
+ ]);
50
+ }
51
+ finally {
52
+ if (timer) {
53
+ clearTimeout(timer);
54
+ }
55
+ }
56
+ }
57
+ /** Parses the stable ownership fields from one Linux `/proc/<pid>/stat`. */
58
+ function parseLinuxProcStat(expectedPid, stat) {
59
+ const commandEnd = stat.lastIndexOf(')');
60
+ const commandStart = stat.indexOf('(');
61
+ if (commandStart <= 0 || commandEnd <= commandStart) {
62
+ throw new Error(`Malformed /proc/${expectedPid}/stat`);
63
+ }
64
+ const parsedPid = Number(stat.slice(0, commandStart).trim());
65
+ const fields = stat.slice(commandEnd + 1).trim().split(/\s+/);
66
+ // fields[0] is state; fields[1..3] are PPID, PGRP, and session.
67
+ const parentPid = Number(fields[1]);
68
+ const ownershipId = Number(fields[3]);
69
+ if (parsedPid !== expectedPid
70
+ || !Number.isSafeInteger(parentPid)
71
+ || parentPid < 0
72
+ || !Number.isSafeInteger(ownershipId)
73
+ || ownershipId < 0) {
74
+ throw new Error(`Malformed /proc/${expectedPid}/stat`);
75
+ }
76
+ return { ownershipId, parentPid, pid: expectedPid };
77
+ }
78
+ async function captureLinuxProcessIds(rootPid, ownershipId, excludedPid, options) {
79
+ const procRoot = options.procRoot ?? '/proc';
80
+ const readDirectory = options.readDirectory
81
+ ?? (directory => (0, promises_1.readdir)(directory));
82
+ const readTextFile = options.readTextFile
83
+ ?? (file => (0, promises_1.readFile)(file, 'utf8'));
84
+ const timeoutMs = options.timeoutMs ?? 750;
85
+ if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
86
+ throw new Error('POSIX process snapshot deadline expired');
87
+ }
88
+ const deadlineAt = Date.now() + timeoutMs;
89
+ const entries = await readDirectory(procRoot);
90
+ assertWithinDeadline(deadlineAt);
91
+ const processIds = entries
92
+ .filter(entry => /^[1-9]\d*$/.test(entry))
93
+ .map(Number)
94
+ .filter(pid => Number.isSafeInteger(pid) && pid <= 0x7fff_ffff);
95
+ if (processIds.length > MAX_PROCESS_COUNT) {
96
+ throw new Error(`Linux process snapshot exceeds ${MAX_PROCESS_COUNT} processes`);
97
+ }
98
+ const records = [];
99
+ let cursor = 0;
100
+ const worker = async () => {
101
+ while (cursor < processIds.length) {
102
+ assertWithinDeadline(deadlineAt);
103
+ const pid = processIds[cursor];
104
+ cursor += 1;
105
+ try {
106
+ const stat = await readTextFile(node_path_1.default.join(procRoot, String(pid), 'stat'));
107
+ records.push(parseLinuxProcStat(pid, stat));
108
+ }
109
+ catch (error) {
110
+ // Process exit is an expected snapshot race. All other read or
111
+ // parse failures make ancestry incomplete and therefore fail
112
+ // closed at the containment caller.
113
+ if (!isVanishedProcEntry(error)) {
114
+ throw error;
115
+ }
116
+ }
117
+ }
118
+ };
119
+ await Promise.all(Array.from({ length: Math.min(PROC_READ_CONCURRENCY, processIds.length) }, worker));
120
+ assertWithinDeadline(deadlineAt);
121
+ return (0, process_tree_js_1.collectPosixOwnedProcessIds)(records, rootPid, excludedPid, ownershipId);
122
+ }
123
+ function resolveDarwinProcessSnapshotAddonPath(arch = process.arch, packageRoot = node_path_1.default.resolve(__dirname, '../..')) {
124
+ if (arch !== 'arm64' && arch !== 'x64') {
125
+ throw new Error(`Darwin process snapshots do not support architecture ${arch}`);
126
+ }
127
+ return node_path_1.default.join(packageRoot, 'bin', 'native', `darwin-${arch}`, 'agent-harness-process-snapshot.node');
128
+ }
129
+ function loadDarwinProcessSnapshotBinding(options) {
130
+ if (options.darwinBinding) {
131
+ return options.darwinBinding;
132
+ }
133
+ const module = loadNativeModule(resolveDarwinProcessSnapshotAddonPath(process.arch, options.packageRoot));
134
+ if (typeof module !== 'object'
135
+ || module === null
136
+ || !('captureOwnedProcessIds' in module)
137
+ || typeof module.captureOwnedProcessIds !== 'function') {
138
+ throw new Error('Darwin process snapshot binding has an invalid API');
139
+ }
140
+ return module;
141
+ }
142
+ function validateNativeProcessIds(result, rootPid, excludedPid) {
143
+ if (!Array.isArray(result) || result.length > MAX_PROCESS_COUNT) {
144
+ throw new Error('Darwin process snapshot binding returned invalid data');
145
+ }
146
+ const processIds = new Set();
147
+ for (const pid of result) {
148
+ if (!Number.isSafeInteger(pid)
149
+ || pid <= 0
150
+ || pid > 0x7fff_ffff) {
151
+ throw new Error('Darwin process snapshot binding returned an invalid PID');
152
+ }
153
+ if (pid !== rootPid && pid !== excludedPid) {
154
+ processIds.add(pid);
155
+ }
156
+ }
157
+ return [...processIds].sort((left, right) => left - right);
158
+ }
159
+ /**
160
+ * Captures a containment-owned POSIX process tree without launching a global
161
+ * process utility. Any unsupported platform or incomplete snapshot rejects.
162
+ */
163
+ async function capturePosixOwnedProcessIds(rootPid, platform, options = {}) {
164
+ assertPositivePid(rootPid, 'rootPid');
165
+ const excludedPid = options.excludedPid ?? process.pid;
166
+ const ownershipId = options.ownershipId ?? rootPid;
167
+ const timeoutMs = options.timeoutMs ?? 750;
168
+ assertPositivePid(excludedPid, 'excludedPid');
169
+ assertPositivePid(ownershipId, 'ownershipId');
170
+ if (!Number.isSafeInteger(timeoutMs)
171
+ || timeoutMs <= 0
172
+ || timeoutMs > 0x7fff_ffff) {
173
+ throw new RangeError('timeoutMs must be a positive 32-bit integer');
174
+ }
175
+ if (platform === 'linux') {
176
+ return settleWithin(captureLinuxProcessIds(rootPid, ownershipId, excludedPid, options), timeoutMs);
177
+ }
178
+ if (platform === 'darwin') {
179
+ const binding = loadDarwinProcessSnapshotBinding(options);
180
+ return validateNativeProcessIds(binding.captureOwnedProcessIds(rootPid, ownershipId, excludedPid, timeoutMs), rootPid, excludedPid);
181
+ }
182
+ throw new Error(`POSIX process snapshots are unsupported on ${platform}`);
183
+ }
@@ -1,13 +1,11 @@
1
- /** Returns the native `ps` ownership key for a detached Node child. */
2
- export declare function posixProcessOwnershipColumn(platform: NodeJS.Platform): 'pgid' | 'sid';
3
- /** Builds a PATH-independent process snapshot command for containment checks. */
4
- export declare function buildPosixProcessListSpec(platform: NodeJS.Platform): {
5
- args: readonly string[];
6
- command: string;
7
- };
1
+ export interface PosixProcessRecord {
2
+ readonly ownershipId: number;
3
+ readonly parentPid: number;
4
+ readonly pid: number;
5
+ }
8
6
  /** ESRCH is the only proof of absence; EPERM still proves the process exists. */
9
7
  export declare function isMissingProcess(error: unknown): boolean;
10
8
  export declare function processExists(pid: number): boolean;
11
9
  export declare function processGroupExists(processGroupId: number): boolean;
12
10
  /** Collects both same-ownership-group members and transitive PPID descendants. */
13
- export declare function collectPosixOwnedProcessIds(output: string, rootPid: number, harnessPid: number): readonly number[];
11
+ export declare function collectPosixOwnedProcessIds(processes: readonly PosixProcessRecord[], rootPid: number, harnessPid: number, ownershipId?: number): readonly number[];
@@ -1,26 +1,9 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.posixProcessOwnershipColumn = posixProcessOwnershipColumn;
4
- exports.buildPosixProcessListSpec = buildPosixProcessListSpec;
5
3
  exports.isMissingProcess = isMissingProcess;
6
4
  exports.processExists = processExists;
7
5
  exports.processGroupExists = processGroupExists;
8
6
  exports.collectPosixOwnedProcessIds = collectPosixOwnedProcessIds;
9
- /** Returns the native `ps` ownership key for a detached Node child. */
10
- function posixProcessOwnershipColumn(platform) {
11
- // A detached Node child leads its process group on Darwin, while procps
12
- // Linux exposes the detached session directly. Darwin accepts `sess` but
13
- // reports 0 here, so it cannot prove ownership of reparented descendants.
14
- return platform === 'darwin' ? 'pgid' : 'sid';
15
- }
16
- /** Builds a PATH-independent process snapshot command for containment checks. */
17
- function buildPosixProcessListSpec(platform) {
18
- const ownershipColumn = posixProcessOwnershipColumn(platform);
19
- return {
20
- args: ['-axo', `pid=,ppid=,${ownershipColumn}=`],
21
- command: '/bin/ps',
22
- };
23
- }
24
7
  /** ESRCH is the only proof of absence; EPERM still proves the process exists. */
25
8
  function isMissingProcess(error) {
26
9
  return error instanceof Error && 'code' in error && error.code === 'ESRCH';
@@ -44,23 +27,7 @@ function processGroupExists(processGroupId) {
44
27
  }
45
28
  }
46
29
  /** Collects both same-ownership-group members and transitive PPID descendants. */
47
- function collectPosixOwnedProcessIds(output, rootPid, harnessPid) {
48
- const processes = [];
49
- for (const line of output.split('\n')) {
50
- const [rawPid, rawParentPid, rawOwnershipId] = line.trim().split(/\s+/, 3);
51
- const pid = Number(rawPid);
52
- const parentPid = Number(rawParentPid);
53
- const ownershipId = Number(rawOwnershipId);
54
- if (Number.isSafeInteger(pid)
55
- && Number.isSafeInteger(parentPid)
56
- && Number.isSafeInteger(ownershipId)
57
- && pid > 0
58
- && parentPid >= 0
59
- && ownershipId >= 0
60
- && pid !== harnessPid) {
61
- processes.push({ ownershipId, parentPid, pid });
62
- }
63
- }
30
+ function collectPosixOwnedProcessIds(processes, rootPid, harnessPid, ownershipId = rootPid) {
64
31
  // forkpty calls setsid(), so a PTY child is no longer in the CodeBuddy
65
32
  // root's session/process group even though its PPID ancestry still belongs
66
33
  // to that root. Combine both views while the root is alive.
@@ -69,13 +36,18 @@ function collectPosixOwnedProcessIds(output, rootPid, harnessPid) {
69
36
  while (changed) {
70
37
  changed = false;
71
38
  for (const entry of processes) {
72
- if (!owned.has(entry.pid)
73
- && (entry.ownershipId === rootPid || owned.has(entry.parentPid))) {
39
+ if (entry.pid !== harnessPid
40
+ && entry.pid > 0
41
+ && entry.parentPid >= 0
42
+ && entry.ownershipId >= 0
43
+ && !owned.has(entry.pid)
44
+ && (entry.ownershipId === ownershipId
45
+ || owned.has(entry.parentPid))) {
74
46
  owned.add(entry.pid);
75
47
  changed = true;
76
48
  }
77
49
  }
78
50
  }
79
51
  owned.delete(rootPid);
80
- return [...owned];
52
+ return [...owned].sort((left, right) => left - right);
81
53
  }
@@ -41,6 +41,12 @@ export interface CreateAgentHarnessNetworkListenerOptions {
41
41
  /** Browser origins are denied unless they are present in this exact allowlist. */
42
42
  allowedOrigins?: readonly string[];
43
43
  limits?: Partial<AgentHarnessNetworkLimits>;
44
+ /** @internal Deterministic HTTP carrier lifecycle observer for transport tests. */
45
+ onHttpSseCarrierStateChange?: (event: {
46
+ readonly connectionId: string;
47
+ readonly sessionId?: string;
48
+ readonly state: 'attached' | 'detached';
49
+ }) => void;
44
50
  onWarning?: (message: string) => void;
45
51
  /** Injectable for deterministic platform checks. */
46
52
  platform?: NodeJS.Platform;
@@ -51,4 +57,4 @@ export interface CreateAgentHarnessNetworkListenerOptions {
51
57
  * Creates the network-side public listener. The protocol acceptor remains the
52
58
  * owner of JSON-RPC validation and initialize/initialized state.
53
59
  */
54
- export declare function createAgentHarnessNetworkListener({ acceptor, listen: rawListen, authenticator, allowInsecureRemote, allowedOrigins, limits: limitOverrides, onWarning, platform, getUid, }: CreateAgentHarnessNetworkListenerOptions): Promise<AgentHarnessListener>;
60
+ export declare function createAgentHarnessNetworkListener({ acceptor, listen: rawListen, authenticator, allowInsecureRemote, allowedOrigins, limits: limitOverrides, onHttpSseCarrierStateChange, onWarning, platform, getUid, }: CreateAgentHarnessNetworkListenerOptions): Promise<AgentHarnessListener>;
@@ -1744,7 +1744,7 @@ function requestPath(request) {
1744
1744
  * Creates the network-side public listener. The protocol acceptor remains the
1745
1745
  * owner of JSON-RPC validation and initialize/initialized state.
1746
1746
  */
1747
- async function createAgentHarnessNetworkListener({ acceptor, listen: rawListen, authenticator, allowInsecureRemote = false, allowedOrigins = [], limits: limitOverrides, onWarning, platform = process.platform, getUid = () => process.getuid?.(), }) {
1747
+ async function createAgentHarnessNetworkListener({ acceptor, listen: rawListen, authenticator, allowInsecureRemote = false, allowedOrigins = [], limits: limitOverrides, onHttpSseCarrierStateChange, onWarning, platform = process.platform, getUid = () => process.getuid?.(), }) {
1748
1748
  const listen = normalizeListenAddress(rawListen);
1749
1749
  if (listen.kind === 'unix' && platform === 'win32') {
1750
1750
  throw new Error('Agent Harness unix:// listeners are only supported on POSIX platforms');
@@ -2005,6 +2005,18 @@ async function createAgentHarnessNetworkListener({ acceptor, listen: rawListen,
2005
2005
  response.setHeader('Access-Control-Expose-Headers', `${exports.AGENT_HARNESS_CONNECTION_ID_HEADER}, ${exports.AGENT_HARNESS_SESSION_ID_HEADER}`);
2006
2006
  response.setHeader('Vary', 'Origin');
2007
2007
  }
2008
+ function recordHttpSseCarrierState(connectionId, sessionId, state) {
2009
+ try {
2010
+ onHttpSseCarrierStateChange?.({
2011
+ connectionId,
2012
+ ...(sessionId === undefined ? {} : { sessionId }),
2013
+ state,
2014
+ });
2015
+ }
2016
+ catch {
2017
+ // Observability must never alter carrier ownership or cleanup.
2018
+ }
2019
+ }
2008
2020
  function createHttpConnection(request) {
2009
2021
  const connectionId = (0, node_crypto_1.randomUUID)();
2010
2022
  const holder = {};
@@ -2159,6 +2171,7 @@ async function createAgentHarnessNetworkListener({ acceptor, listen: rawListen,
2159
2171
  sink = new SseSink(response, state.budget, outboundBudget, carrierDisconnected => {
2160
2172
  if (state.sinks.get(scope) === sink) {
2161
2173
  state.sinks.delete(scope);
2174
+ recordHttpSseCarrierState(state.connection.id, sessionId, 'detached');
2162
2175
  }
2163
2176
  releaseLease();
2164
2177
  if (sessionId !== undefined) {
@@ -2177,6 +2190,7 @@ async function createAgentHarnessNetworkListener({ acceptor, listen: rawListen,
2177
2190
  throw error;
2178
2191
  }
2179
2192
  state.sinks.set(scope, sink);
2193
+ recordHttpSseCarrierState(state.connection.id, sessionId, 'attached');
2180
2194
  const backlog = state.backlogs.get(scope) ?? [];
2181
2195
  state.backlogs.delete(scope);
2182
2196
  for (let index = 0; index < backlog.length; index += 1) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tencent-ai/agent-harness",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "Standalone JSON-RPC agent harness backed by CodeBuddy Code",
5
5
  "license": "MIT",
6
6
  "bin": {
@@ -133,8 +133,8 @@
133
133
  "@types/ws": "^8.18.1",
134
134
  "typescript": "npm:@typescript/typescript6@^6.0.0",
135
135
  "vitest": "^4.1.10",
136
- "@genie/vitest-config": "0.0.0",
137
- "@genie/style-guide": "0.0.0"
136
+ "@genie/style-guide": "0.0.0",
137
+ "@genie/vitest-config": "0.0.0"
138
138
  },
139
139
  "engines": {
140
140
  "node": ">=22 <=24"
@@ -253,7 +253,9 @@
253
253
  "build:lib": "pnpm --workspace-root exec nx run @genie/agent-harness:build:lib --skipSync",
254
254
  "build:lib:run": "pnpm run clean && pnpm run sync:codebuddy:product && tsc --build tsconfig.lib.json executor/tsconfig.json --force && pnpm --dir executor run build:capsule && node scripts/write-product-schema.mjs",
255
255
  "build:run": "pnpm run build:lib:run && pnpm run sync:codebuddy:runtime",
256
+ "build:native:darwin": "node scripts/build-darwin-process-snapshot.mjs",
256
257
  "build:native:windows": "node scripts/build-windows-job-launcher.mjs",
258
+ "check:native:darwin": "node scripts/build-darwin-process-snapshot.mjs --check",
257
259
  "check:native:windows": "node scripts/build-windows-job-launcher.mjs --check",
258
260
  "clean": "node -e \"const fs=require('node:fs'); for (const path of ['lib','cli','executor/lib']) fs.rmSync(path, { force: true, recursive: true })\"",
259
261
  "lint": "pnpm --dir executor run lint && genie-lint",