sept-agent 0.1.2 → 0.1.4

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 (52) hide show
  1. package/CONTRIBUTING.md +8 -4
  2. package/README.md +33 -8
  3. package/dist/src/cli/commands.js +4 -0
  4. package/dist/src/cli/commands.js.map +1 -1
  5. package/dist/src/cli/hands-docker.d.ts +7 -0
  6. package/dist/src/cli/hands-docker.js +182 -0
  7. package/dist/src/cli/hands-docker.js.map +1 -0
  8. package/dist/src/cli/hands-finish.d.ts +2 -0
  9. package/dist/src/cli/hands-finish.js +164 -0
  10. package/dist/src/cli/hands-finish.js.map +1 -0
  11. package/dist/src/cli/hands-owner.d.ts +10 -0
  12. package/dist/src/cli/hands-owner.js +83 -0
  13. package/dist/src/cli/hands-owner.js.map +1 -0
  14. package/dist/src/cli/hands-record.d.ts +33 -0
  15. package/dist/src/cli/hands-record.js +164 -0
  16. package/dist/src/cli/hands-record.js.map +1 -0
  17. package/dist/src/cli/hands.d.ts +3 -0
  18. package/dist/src/cli/hands.js +169 -0
  19. package/dist/src/cli/hands.js.map +1 -0
  20. package/dist/src/cli/options.d.ts +1 -0
  21. package/dist/src/cli/options.js +2 -1
  22. package/dist/src/cli/options.js.map +1 -1
  23. package/dist/src/cli.js +1 -1
  24. package/dist/src/cli.js.map +1 -1
  25. package/dist/src/execution/hands-process-owner.d.ts +2 -4
  26. package/dist/src/execution/hands-process-owner.js +3 -1
  27. package/dist/src/execution/hands-process-owner.js.map +1 -1
  28. package/dist/src/execution/index.d.ts +4 -0
  29. package/dist/src/execution/index.js +4 -0
  30. package/dist/src/execution/index.js.map +1 -1
  31. package/examples/incident-investigator/README.md +4 -4
  32. package/examples/incident-investigator/package.json +1 -1
  33. package/examples/node-app/README.md +4 -4
  34. package/examples/node-app/package.json +1 -1
  35. package/package.json +1 -8
  36. package/src/cli/commands.ts +4 -0
  37. package/src/cli/hands-docker.ts +198 -0
  38. package/src/cli/hands-finish.ts +204 -0
  39. package/src/cli/hands-owner.ts +118 -0
  40. package/src/cli/hands-record.ts +213 -0
  41. package/src/cli/hands.ts +207 -0
  42. package/src/cli/options.ts +3 -1
  43. package/src/cli.ts +1 -1
  44. package/src/execution/hands-process-owner.ts +4 -5
  45. package/src/execution/index.ts +10 -0
  46. package/docs/architecture.md +0 -168
  47. package/docs/index.md +0 -55
  48. package/docs/models.md +0 -78
  49. package/docs/observability.md +0 -63
  50. package/docs/pi-contract.md +0 -482
  51. package/docs/sdk.md +0 -180
  52. package/docs/worker-api.md +0 -138
@@ -9,4 +9,8 @@ export { capabilityBinding, defaultCapabilities, validateCapabilities, capabilit
9
9
  export { initializeCapabilityEngine, validateDockerConfiguration, revokeDockerBinding, revokeRecordedHandsBinding, reconcileDockerBinding, assertHandsRunProfile, } from "./docker-control.js";
10
10
  /** Exact-family filter generation for trusted consumer-owned network setup. */
11
11
  export { networkFirewall } from "./network-profile.js";
12
+ /** Local setup uses the same authenticated Engine and pinned namespace cleanup as the worker. */
13
+ export { DockerEngineControl } from "./docker-engine.js";
14
+ export { captureNamespaceLifetime, stopCapturedNamespace, parseNamespaceLifetime, } from "./hands-processes.js";
15
+ export { guardedCommand } from "./hands-guarded-command.js";
12
16
  //# sourceMappingURL=index.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../../../src/execution/index.ts"],"names":[],"mappings":"AAAA,qGAAqG;AACrG,OAAO,EACL,4BAA4B,GAO7B,MAAM,gBAAgB,CAAC;AACxB,OAAO,EAAE,sBAAsB,EAAE,MAAM,qBAAqB,CAAC;AAC7D,OAAO,EAAE,mBAAmB,EAAE,MAAM,iBAAiB,CAAC;AACtD,OAAO,EAAE,sBAAsB,EAAE,qBAAqB,EAAE,MAAM,cAAc,CAAC;AAC7E,OAAO,EAAE,mBAAmB,EAAE,oBAAoB,EAAE,MAAM,eAAe,CAAC;AAE1E,OAAO,EACL,iBAAiB,EACjB,mBAAmB,EACnB,oBAAoB,EACpB,eAAe,EACf,gBAAgB,EAChB,sBAAsB,GACvB,MAAM,2BAA2B,CAAC;AAEnC,qGAAqG;AACrG,OAAO,EACL,0BAA0B,EAC1B,2BAA2B,EAC3B,mBAAmB,EACnB,0BAA0B,EAC1B,sBAAsB,EACtB,qBAAqB,GACtB,MAAM,qBAAqB,CAAC;AAE7B,+EAA+E;AAC/E,OAAO,EAAE,eAAe,EAAE,MAAM,sBAAsB,CAAC"}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../../../src/execution/index.ts"],"names":[],"mappings":"AAAA,qGAAqG;AACrG,OAAO,EACL,4BAA4B,GAO7B,MAAM,gBAAgB,CAAC;AACxB,OAAO,EAAE,sBAAsB,EAAE,MAAM,qBAAqB,CAAC;AAC7D,OAAO,EAAE,mBAAmB,EAAE,MAAM,iBAAiB,CAAC;AACtD,OAAO,EAAE,sBAAsB,EAAE,qBAAqB,EAAE,MAAM,cAAc,CAAC;AAC7E,OAAO,EAAE,mBAAmB,EAAE,oBAAoB,EAAE,MAAM,eAAe,CAAC;AAE1E,OAAO,EACL,iBAAiB,EACjB,mBAAmB,EACnB,oBAAoB,EACpB,eAAe,EACf,gBAAgB,EAChB,sBAAsB,GACvB,MAAM,2BAA2B,CAAC;AAEnC,qGAAqG;AACrG,OAAO,EACL,0BAA0B,EAC1B,2BAA2B,EAC3B,mBAAmB,EACnB,0BAA0B,EAC1B,sBAAsB,EACtB,qBAAqB,GACtB,MAAM,qBAAqB,CAAC;AAE7B,+EAA+E;AAC/E,OAAO,EAAE,eAAe,EAAE,MAAM,sBAAsB,CAAC;AAEvD,iGAAiG;AACjG,OAAO,EAAE,mBAAmB,EAAE,MAAM,oBAAoB,CAAC;AACzD,OAAO,EACL,wBAAwB,EACxB,qBAAqB,EACrB,sBAAsB,GACvB,MAAM,sBAAsB,CAAC;AAE9B,OAAO,EAAE,cAAc,EAAE,MAAM,4BAA4B,CAAC"}
@@ -10,9 +10,9 @@ npm install --omit=dev --save-exact "$SEPT_TARBALL"
10
10
  npm ls --omit=dev --json > production-dependencies.json
11
11
  ```
12
12
 
13
- Follow the [supplied-hands setup](../../docs/pi-contract.md#executable-supplied-hands-setup) using **separate fresh** brain/agent/state/workspace/initially-empty resources/profile/generation from the native file example. Valid hands is mandatory even for inline text. Before any worker/factory/direct initialization, installed `permissions show` must show absent policy/revision0; installed `permissions set --revision 0` sets scoped workspace read/write false, selected empty, execute/network false. Assert `freshBackendRequired:false` and unchanged running generation. Keep network none and `shell:false`; do not grant file/shell/network rights to make this app pass.
13
+ Follow the [fresh-instance setup](../../README.md#install-and-prepare) with `--root "$SEPT_INCIDENT_APP/instance" --access none`, choosing the actual local Engine socket and locally available image. Use an instance separate from the native file example. Valid hands is mandatory even for inline text. The helper activates zero workspace/selected/execute/network rights before worker initialization; do not grant file/shell/network rights to make this app pass.
14
14
 
15
- Configure a suitable provider/model in the private agent directory using [models](../../docs/models.md). Start the installed complete worker with explicit paths and a privately supplied token (or use the [Node host](../node-app/worker.mjs) with these incident roots). The app's five explicit inputs are the authenticated worker origin, token, workspace, provider and model; a stable event ID identifies the logical investigation.
15
+ Set `SEPT_STATE_DIR`, `PI_CODING_AGENT_DIR`, `SEPT_WORKSPACE_ROOT` and `SEPT_HANDS_CONFIGURATION` from setup's `stateRoot`, `agentDir`, `workspaceRoot` and `handsConfigurationPath` fields. Configure a suitable provider/model through [model authentication](../../README.md#model-authentication). Start the installed complete worker with explicit paths and a privately supplied token (or use the [Node host](../node-app/worker.mjs) with these incident roots). The app's five explicit inputs are the authenticated worker origin, token, workspace, provider and model; a stable event ID identifies the logical investigation.
16
16
 
17
17
  ```sh
18
18
  sept worker --state-dir "$SEPT_STATE_DIR" --agent-dir "$PI_CODING_AGENT_DIR" \
@@ -32,7 +32,7 @@ node investigate.mjs wait
32
32
 
33
33
  The `wait` command prints authoritative WAIT and exits, deliberately retaining the coherent waiting run. Stop/join the worker. Export any needed workspace, then stop and verify its exact owned generation using the supplied-hands guards. Create a **fresh minimal** generation and a dedicated initially-empty resources root/profile; preserve the same private state/agent/workspace roots and model selection. Use installed `permissions show` to obtain the current revision and `permissions set --revision CURRENT` with the same zero-rights policy and fresh profile; confirm `freshBackendRequired:false` and the fresh running generation. Start the worker again with that profile and the same persistent roots. The earlier generation must remain stopped/PID0; never reuse it.
34
34
 
35
- With the original setup's explicit-socket `sept_docker`/`sept_socket_matches` functions and exported roots/image still in this operator shell, the fresh generation and current-revision activation are:
35
+ The fresh-instance helper does not orchestrate this durable restart/rebind. The advanced operator-managed snippet below requires separately supplied `sept_docker`/`sept_socket_matches` guards pinned to the original local socket identity and daemon, `SEPT_ENGINE_ID`, immutable `SEPT_IMAGE`, private `SEPT_OPERATOR_ROOT`, `SEPT_EVIDENCE/socket.json`, and `SEPT_OPERATOR_ROOT/minimal-policy.json` containing the same zero-rights policy. These guards, receipts and exact-generation finalizers are prerequisites, not outputs of the helper. With those existing operator inputs, the fresh generation and current-revision activation are:
36
36
 
37
37
  ```sh
38
38
  set -eu
@@ -105,4 +105,4 @@ Expected output is a `WAIT` record from `getTask`, followed by a `report` record
105
105
 
106
106
  On failure or SIGINT/SIGTERM, `finally` independently attempts bounded cancellation of an unfinished admitted run. Cancellation can return while settlement continues; inspect authoritative state and reconcile an ambiguous request. The app does not own an external worker/provider/hands process. Their operator must independently stop/join each worker/provider/listener/attachment/lease and the exact owned generation using the setup's authority guards, verify stopped/PID0, and retain mutexes/stopped resources/recovery. A successful wait command retains WAIT; a successful report retains the terminal run. The external worker remains under its caller's lifecycle control.
107
107
 
108
- Persistent WAIT/stop/reopen is supported by the [SDK lifecycle](../../docs/sdk.md#worker-lifecycle): await stop, supply a fresh hands generation under current revision CAS, reopen the same persistent roots, and then send later evidence. Do not reuse a stopped generation or reset the task's budget/history. Provider outages/OAuth refresh, full Darwin/Intel/Windows and arbitrary Docker hosts remain unqualified.
108
+ Persistent WAIT/stop/reopen requires the existing SDK lifecycle: await stop, supply a fresh hands generation under current revision CAS, reopen the same persistent roots, and then send later evidence. Do not reuse a stopped generation or reset the task's budget/history. Provider outages/OAuth refresh, full Darwin/Intel/Windows and arbitrary Docker hosts remain unqualified.
@@ -10,6 +10,6 @@
10
10
  "report": "node investigate.mjs report"
11
11
  },
12
12
  "dependencies": {
13
- "sept-agent": "0.1.2"
13
+ "sept-agent": "0.1.4"
14
14
  }
15
15
  }
@@ -30,9 +30,9 @@ npm run build:extension
30
30
 
31
31
  No installed header, ambient shim, alias or skipped declaration check is used. This configuration resolves the declarations actually reached by the extension; it does not repair Pi AI's separate model-catalog JSON imports without NodeNext attributes. Importing that broader catalog still needs upstream repair. SEPT's public package declarations remain independently checked with Node-only types.
32
32
 
33
- Follow [supplied-hands setup](../../docs/pi-contract.md#executable-supplied-hands-setup) with `SEPT_OPERATIONS_ROOT="$SEPT_APP"`. Use a **fresh separate** state/workspace/initially-empty resources/profile/generation for this example. Before any worker initialization, installed `sept permissions show` must show absent revision0, then installed `permissions set --revision 0` grants scoped workspace read/write true, selected empty, execute/network false. Confirm `freshBackendRequired:false` and the unchanged running generation. Keep `shell:false` and network none. Never widen the incident app's policy for this example.
33
+ Follow the [fresh-instance setup](../../README.md#install-and-prepare) with `--root "$SEPT_APP/instance" --access files`, choosing the actual local Engine socket and locally available image. Use a fresh separate instance for this example. The helper activates workspace read/write before worker initialization and leaves selected mounts, shell and networking disabled. Never widen the incident app's policy for this example.
34
34
 
35
- Set `PI_CODING_AGENT_DIR`, `SEPT_STATE_DIR`, `SEPT_WORKSPACE_ROOT`, `SEPT_HANDS_CONFIGURATION` to the setup's absolute paths, and supply `SEPT_TOKEN` privately. Configure the desired provider/model through [Pi model configuration](../../docs/models.md) in that agent directory. The physical agent directory must be below this external app, so normal ancestor lookup finds its matching `node_modules`.
35
+ Set `PI_CODING_AGENT_DIR`, `SEPT_STATE_DIR`, `SEPT_WORKSPACE_ROOT`, `SEPT_HANDS_CONFIGURATION` to the setup's absolute `agentDir`, `stateRoot`, `workspaceRoot`, `handsConfigurationPath` fields, and supply `SEPT_TOKEN` privately. Configure the desired provider/model through [model authentication](../../README.md#model-authentication) in that agent directory. The physical agent directory must be below this external app, so normal ancestor lookup finds its matching `node_modules`.
36
36
 
37
37
  ```sh
38
38
  test -f extension-dist/example.js
@@ -56,9 +56,9 @@ export SEPT_EVENT_ID=node-marker-001
56
56
  node dist/client.js
57
57
  ```
58
58
 
59
- The client requires authoritative complete state and prints its bounded public conversation. Actual acceptance additionally requires the section in the real provider request, Harness selecting `write_marker`, and exported hands bytes exactly `SEPT_NATIVE_EXAMPLE` plus newline. Compilation or directly calling the extension object cannot establish this. Use the setup's explicit-socket export command to retain `/workspace` before stopping the exact owned generation.
59
+ The client requires authoritative complete state and prints its bounded public conversation. Actual acceptance additionally requires the section in the real provider request, Harness selecting `write_marker`, and exported hands bytes exactly `SEPT_NATIVE_EXAMPLE` plus newline. Compilation or directly calling the extension object cannot establish this.
60
60
 
61
- Send SIGINT/SIGTERM to the worker host; it awaits `worker.stop()` and removes its handlers. Readiness covers listener/router and durable adoption admission; native execution may still fail afterward and is observed through the task API. Normal stop retains idle hands. Follow the setup's independent worker/provider/listener/lease/attachment and exact-generation export/stop finalizers, verify stopped/PID0, and retain stopped resources/mutexes/recovery. Cancel/loss can stop hands earlier, so later export is not guaranteed.
61
+ Send SIGINT/SIGTERM to the worker host; it awaits `worker.stop()` and removes its handlers. Readiness covers listener/router and durable adoption admission; native execution may still fail afterward and is observed through the task API. Normal stop retains idle hands. After the host has joined, run `sept hands finish --root "$SEPT_APP/instance"` to retain the private workspace archive and verify the exact owned generation stopped/PID0. Preserve its receipts and archive. Cancel/loss can stop hands earlier, so later export is not guaranteed.
62
62
 
63
63
  After editing an extension, stop/join the host and start a **fresh Node process**. Stable canonical module-URL caching survives Pi host replacement and same-process stop/reopen; reopening resumes durable state without promising edited-code reload. There is no hot reload.
64
64
 
@@ -12,7 +12,7 @@
12
12
  "client": "node dist/client.js"
13
13
  },
14
14
  "dependencies": {
15
- "sept-agent": "0.1.2",
15
+ "sept-agent": "0.1.4",
16
16
  "@earendil-works/pi-durable": "1.1.0",
17
17
  "@earendil-works/pi-ai": "1.1.0"
18
18
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sept-agent",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
4
4
  "description": "Durable Pi-native single-slot task worker",
5
5
  "type": "module",
6
6
  "engines": {
@@ -77,13 +77,6 @@
77
77
  "CONTRIBUTING.md",
78
78
  "LICENSE",
79
79
  "NOTICE",
80
- "docs/index.md",
81
- "docs/sdk.md",
82
- "docs/models.md",
83
- "docs/worker-api.md",
84
- "docs/pi-contract.md",
85
- "docs/architecture.md",
86
- "docs/observability.md",
87
80
  "examples/node-app",
88
81
  "examples/incident-investigator",
89
82
  "image/pi-native-search/README.md",
@@ -19,6 +19,7 @@ import {
19
19
  createWorker,
20
20
  type Worker,
21
21
  } from "../worker/index.js";
22
+ import { runHands } from "./hands.js";
22
23
  import { runPermissions } from "./permissions.js";
23
24
  import { resumePermissionRun, assertLocalRunSlot } from "./permission-decisions.js";
24
25
  import { CliError } from "./errors.js";
@@ -291,6 +292,9 @@ async function runHealth(context: CommandContext): Promise<void> {
291
292
  /** Execute one parsed command through public mechanism boundaries without retaining credentials. */
292
293
  export async function executeCommand(context: CommandContext): Promise<void> {
293
294
  switch (context.command) {
295
+ case "hands":
296
+ writeJson(await runHands(context));
297
+ return;
294
298
  case "permissions":
295
299
  writeJson(await runPermissions(context, stateRoot(context.options), context.agentDir));
296
300
  return;
@@ -0,0 +1,198 @@
1
+ /** Setup-only Docker CLI calls use one explicit socket and no ambient Docker context or credentials. */
2
+ import { spawn, spawnSync } from "node:child_process";
3
+ import { createWriteStream, fsyncSync, closeSync, openSync } from "node:fs";
4
+ import { join } from "node:path";
5
+ import { Transform } from "node:stream";
6
+ import { pipeline } from "node:stream/promises";
7
+ import { SeptError } from "../durable/index.js";
8
+ import { guardedCommand, parseNamespaceLifetime } from "../execution/index.js";
9
+ import { readHandsMetadata, type HandsReceipt } from "./hands-record.js";
10
+
11
+ // Lifetime is checked inside the exec: a replacement namespace can never emit an archive.
12
+ const ARCHIVE_ENTRY = `const fs = require('node:fs');
13
+ const { spawnSync } = require('node:child_process');
14
+ const stat = fs.readFileSync('/proc/1/stat', 'utf8');
15
+ if (stat.length > 16384) process.exit(126);
16
+ const start = stat.slice(stat.lastIndexOf(')') + 2).split(' ')[19];
17
+ if (fs.readlinkSync('/proc/self/ns/pid') !== process.argv[1] || start !== process.argv[2]) process.exit(126);
18
+ const result = spawnSync('/bin/tar', ['-C', '/workspace', '-cf', '-', '.'], { stdio: 'inherit', env: { PATH: '/usr/bin:/bin' }, timeout: 25000 });
19
+ process.exit(result.status === 0 && result.signal === null ? 0 : 126);`;
20
+
21
+ const MAX_ARCHIVE_BYTES = 134217728 + 32768 * 2048;
22
+
23
+ function commandArguments(root: string, receipt: HandsReceipt, arguments_: string[]): string[] {
24
+ return [
25
+ "--config",
26
+ join(root, "docker-cli"),
27
+ "--host",
28
+ `unix://${receipt.engineSocket}`,
29
+ ...arguments_,
30
+ ];
31
+ }
32
+
33
+ /** Bounded setup controls never invoke a shell, pull an image, or read the user's Docker config. */
34
+ export function handsDocker(root: string, receipt: HandsReceipt, arguments_: string[]): string {
35
+ const result = spawnSync("docker", commandArguments(root, receipt, arguments_), {
36
+ env: { PATH: process.env.PATH ?? "/usr/local/bin:/usr/bin:/bin" },
37
+ encoding: "utf8",
38
+ timeout: 30_000,
39
+ maxBuffer: 16 * 1024,
40
+ });
41
+ if (result.status !== 0 || result.signal !== null || result.error !== undefined) {
42
+ throw new SeptError("Hands setup Docker command failed; retained receipt requires inspection", {
43
+ cause: result.error,
44
+ });
45
+ }
46
+ return result.stdout.trim();
47
+ }
48
+
49
+ /** The sole supported offline, file-only profile uses existing finite kernel limits. */
50
+ export function createHandsArguments(root: string, receipt: HandsReceipt): string[] {
51
+ return [
52
+ "create",
53
+ "--pull",
54
+ "never",
55
+ "--name",
56
+ `sept-hands-${receipt.owner}`,
57
+ "--label",
58
+ `sept.setup.owner=${receipt.owner}`,
59
+ "--init",
60
+ "--no-healthcheck",
61
+ "--entrypoint",
62
+ "",
63
+ "--user",
64
+ "10002:10002",
65
+ "--read-only",
66
+ "--cap-drop",
67
+ "ALL",
68
+ "--security-opt",
69
+ "no-new-privileges",
70
+ "--pids-limit",
71
+ "128",
72
+ "--memory",
73
+ "1g",
74
+ "--cpus",
75
+ "2",
76
+ "--network",
77
+ "none",
78
+ "--tmpfs",
79
+ "/tmp:rw,nosuid,nodev,noexec,size=16777216,mode=1777",
80
+ "--tmpfs",
81
+ "/workspace:rw,nosuid,nodev,noexec,size=134217728,nr_inodes=32768,mode=2770,uid=10002,gid=10002",
82
+ "--mount",
83
+ `type=bind,src=${join(root, "resources")},dst=/resources,readonly`,
84
+ receipt.image,
85
+ "sleep",
86
+ "infinity",
87
+ ];
88
+ }
89
+
90
+ /** Stream a bounded tar archive to a new private file; hostile archive paths are never extracted. */
91
+ export async function exportHandsWorkspace(
92
+ root: string,
93
+ receipt: HandsReceipt,
94
+ path: string,
95
+ ): Promise<void> {
96
+ if (receipt.containerId === undefined) {
97
+ throw new SeptError("Hands export requires captured container ownership");
98
+ }
99
+ const child = archiveChild(root, receipt);
100
+ const completion = childCompletion(child);
101
+ const timer = setTimeout(() => {
102
+ child.kill("SIGKILL");
103
+ }, 30_000);
104
+ try {
105
+ await archivePipeline(child.stdout, path);
106
+ await completion;
107
+ syncArchive(path);
108
+ } catch (error: unknown) {
109
+ child.kill("SIGKILL");
110
+ await completion.catch(() => undefined);
111
+ throw new SeptError("Hands workspace export failed; partial archive retained", {
112
+ cause: error,
113
+ });
114
+ } finally {
115
+ clearTimeout(timer);
116
+ }
117
+ }
118
+
119
+ function archiveChild(
120
+ root: string,
121
+ receipt: HandsReceipt,
122
+ ): import("node:child_process").ChildProcessWithoutNullStreams {
123
+ if (receipt.containerId === undefined) {
124
+ throw new SeptError("Hands export requires captured container ownership");
125
+ }
126
+ const child = spawn(
127
+ "docker",
128
+ commandArguments(root, receipt, [
129
+ "exec",
130
+ "--user",
131
+ "10002:10002",
132
+ receipt.containerId,
133
+ ...archiveCommand(root),
134
+ ]),
135
+ {
136
+ env: { PATH: process.env.PATH ?? "/usr/local/bin:/usr/bin:/bin" },
137
+ stdio: ["pipe", "pipe", "pipe"],
138
+ },
139
+ );
140
+ child.stdin.end();
141
+ let stderrBytes = 0;
142
+ child.stderr.on("data", (chunk: Buffer) => {
143
+ stderrBytes += chunk.byteLength;
144
+ if (stderrBytes > 16 * 1024) {
145
+ child.kill("SIGKILL");
146
+ }
147
+ });
148
+ return child;
149
+ }
150
+
151
+ function archiveCommand(root: string): string[] {
152
+ const path = join(root, "evidence", "lifetime.json");
153
+ const raw = readHandsMetadata(path, 1024);
154
+ const lifetime = parseNamespaceLifetime(raw);
155
+ return guardedCommand(["--eval", ARCHIVE_ENTRY, lifetime.pidNamespace, lifetime.initStart]);
156
+ }
157
+
158
+ function childCompletion(child: import("node:child_process").ChildProcess): Promise<void> {
159
+ const completed = new Promise<void>((resolve, reject) => {
160
+ child.once("error", () => {
161
+ reject(new SeptError("Hands archive process could not start"));
162
+ });
163
+ child.once("close", (code, signal) => {
164
+ if (code === 0 && signal === null) {
165
+ resolve();
166
+ } else {
167
+ reject(new SeptError("Hands archive process failed"));
168
+ }
169
+ });
170
+ });
171
+ // Attach a rejection owner immediately: an Engine failure may arrive before stream finalization.
172
+ void completed.catch(() => undefined);
173
+ return completed;
174
+ }
175
+
176
+ async function archivePipeline(source: NodeJS.ReadableStream, path: string): Promise<void> {
177
+ let bytes = 0;
178
+ const bound = new Transform({
179
+ transform(chunk: Buffer, _encoding, callback) {
180
+ bytes += chunk.byteLength;
181
+ if (bytes > MAX_ARCHIVE_BYTES) {
182
+ callback(new SeptError("Hands workspace archive exceeds its fixed capacity"));
183
+ } else {
184
+ callback(null, chunk);
185
+ }
186
+ },
187
+ });
188
+ await pipeline(source, bound, createWriteStream(path, { flags: "wx", mode: 0o600 }));
189
+ }
190
+
191
+ function syncArchive(path: string): void {
192
+ const descriptor = openSync(path, "r");
193
+ try {
194
+ fsyncSync(descriptor);
195
+ } finally {
196
+ closeSync(descriptor);
197
+ }
198
+ }
@@ -0,0 +1,204 @@
1
+ /** Export before stopping one exclusively owned hands generation; never extract archive bytes on host. */
2
+ import { existsSync, lstatSync } from "node:fs";
3
+ import { join, dirname, basename } from "node:path";
4
+ import { randomUUID } from "node:crypto";
5
+ import { acquireStateRootLease, atomicWrite, SeptError } from "../durable/index.js";
6
+ import {
7
+ handsPaths,
8
+ readHandsReceipt,
9
+ readHandsMetadata,
10
+ trustedHandsRoot,
11
+ writeHandsReceipt,
12
+ type HandsReceipt,
13
+ } from "./hands-record.js";
14
+ import { exportHandsWorkspace } from "./hands-docker.js";
15
+ import { inspectHandsOwner, openHandsEngine, stopHandsOwner } from "./hands-owner.js";
16
+ /** Refuse a live worker, serialize setup ownership, then independently finalize export and stop. */
17
+ export async function finishHands(options: Record<string, string>): Promise<unknown> {
18
+ if (Object.keys(options).some((key) => key !== "root")) {
19
+ throw new SeptError("hands finish accepts only --root");
20
+ }
21
+ const root = trustedHandsRoot(options.root);
22
+ assertSetupLayout(root);
23
+ // Preflight schema grants no authority; the owner is re-read after serialized lease acquisition.
24
+ readHandsReceipt(root);
25
+ const releaseSetup = acquireStateRootLease(join(root, "evidence"));
26
+ try {
27
+ const releaseWorker = acquireStateRootLease(handsPaths(root).stateRoot);
28
+ try {
29
+ return await finishOwned(root, readHandsReceipt(root));
30
+ } finally {
31
+ releaseWorker();
32
+ }
33
+ } finally {
34
+ releaseSetup();
35
+ }
36
+ }
37
+
38
+ function assertSetupLayout(root: string): void {
39
+ try {
40
+ // Lease acquisition creates lock files: prove a setup-owned layout before any such mutation.
41
+ for (const name of ["state", "evidence"]) {
42
+ const directory = lstatSync(join(root, name));
43
+ if (
44
+ !directory.isDirectory() ||
45
+ directory.uid !== process.getuid?.() ||
46
+ (directory.mode & 0o077) !== 0
47
+ ) {
48
+ throw new SeptError("Hands setup directories must be private and owned");
49
+ }
50
+ }
51
+ assertSetupReceipt(root);
52
+ } catch (error: unknown) {
53
+ throw new SeptError("Hands finish requires an existing private owned setup layout", {
54
+ cause: error,
55
+ });
56
+ }
57
+ }
58
+
59
+ function assertSetupReceipt(root: string): void {
60
+ const receipt = lstatSync(join(root, "evidence", "setup.json"));
61
+ if (
62
+ !receipt.isFile() ||
63
+ receipt.nlink !== 1 ||
64
+ receipt.uid !== process.getuid?.() ||
65
+ (receipt.mode & 0o077) !== 0 ||
66
+ receipt.size > 16 * 1024
67
+ ) {
68
+ throw new SeptError("Hands setup receipt must be private and owned");
69
+ }
70
+ }
71
+
72
+ async function finishOwned(root: string, receipt: HandsReceipt): Promise<unknown> {
73
+ const previous = reusableExport(root);
74
+ const engine = await openHandsEngine(receipt);
75
+ const archive = join(root, "evidence", `workspace-${randomUUID()}.tar`);
76
+ let exported = previous !== undefined;
77
+ let stopped = false;
78
+ try {
79
+ const state = await inspectHandsOwner(root, receipt, engine);
80
+ if (receipt.phase === "finished") {
81
+ return verifiedFinalization(root, state, previous);
82
+ }
83
+ exported ||= await tryExport(root, receipt, archive, state.Running === true);
84
+ await stopHandsOwner(root, receipt, engine);
85
+ stopped = true;
86
+ } finally {
87
+ await engine.close();
88
+ if (receipt.phase !== "finished") {
89
+ atomicWrite(join(root, "evidence", "finish.json"), {
90
+ exported,
91
+ stopped,
92
+ archive: previous ?? (existsSync(archive) ? archive : null),
93
+ });
94
+ }
95
+ }
96
+ if (!exported) {
97
+ throw new SeptError(
98
+ "Hands workspace export failed or unavailable; stop verified; inspect evidence/finish.json",
99
+ );
100
+ }
101
+ receipt.phase = "finished";
102
+ writeHandsReceipt(root, receipt);
103
+ return readFinalization(root);
104
+ }
105
+
106
+ function verifiedFinalization(
107
+ root: string,
108
+ state: Record<string, unknown>,
109
+ previous: string | undefined,
110
+ ): Finalization {
111
+ if (state.Running !== false || state.Pid !== 0 || previous === undefined) {
112
+ throw new SeptError(
113
+ "Previously finished hands has no verified stopped namespace and retained archive",
114
+ );
115
+ }
116
+ const result = readFinalization(root);
117
+ if (!result.exported || !result.stopped) {
118
+ throw new SeptError("Hands finished receipt has no verified successful outcomes");
119
+ }
120
+ return result;
121
+ }
122
+
123
+ interface Finalization {
124
+ exported: boolean;
125
+ stopped: boolean;
126
+ archive: string | null;
127
+ }
128
+
129
+ function readFinalization(root: string): Finalization {
130
+ const result = readHandsMetadata(join(root, "evidence", "finish.json"), 1024);
131
+ if (
132
+ typeof result !== "object" ||
133
+ result === null ||
134
+ !("exported" in result) ||
135
+ !("stopped" in result) ||
136
+ !("archive" in result)
137
+ ) {
138
+ throw new SeptError("Hands finalization receipt is malformed");
139
+ }
140
+ if (
141
+ typeof result.exported !== "boolean" ||
142
+ typeof result.stopped !== "boolean" ||
143
+ (typeof result.archive !== "string" && result.archive !== null)
144
+ ) {
145
+ throw new SeptError("Hands finalization outcomes are malformed");
146
+ }
147
+ return { exported: result.exported, stopped: result.stopped, archive: result.archive };
148
+ }
149
+
150
+ function reusableExport(root: string): string | undefined {
151
+ try {
152
+ return previousExport(root);
153
+ } catch {
154
+ // Invalid export evidence grants no reuse, but cannot skip independently owned namespace shutdown.
155
+ return undefined;
156
+ }
157
+ }
158
+
159
+ function previousExport(root: string): string | undefined {
160
+ if (!existsSync(join(root, "evidence", "finish.json"))) {
161
+ return undefined;
162
+ }
163
+ const previous = readFinalization(root);
164
+ if (!previous.exported || previous.archive === null) {
165
+ return undefined;
166
+ }
167
+ if (
168
+ dirname(previous.archive) !== join(root, "evidence") ||
169
+ !/^workspace-[a-f0-9-]{36}\.tar$/.test(basename(previous.archive))
170
+ ) {
171
+ throw new SeptError("Hands archive receipt escapes the owned evidence directory");
172
+ }
173
+ assertArchive(previous.archive);
174
+ return previous.archive;
175
+ }
176
+
177
+ function assertArchive(path: string): void {
178
+ const file = lstatSync(path);
179
+ if (
180
+ !file.isFile() ||
181
+ file.nlink !== 1 ||
182
+ file.uid !== process.getuid?.() ||
183
+ (file.mode & 0o077) !== 0
184
+ ) {
185
+ throw new SeptError("Hands retained archive ownership is malformed");
186
+ }
187
+ }
188
+
189
+ async function tryExport(
190
+ root: string,
191
+ receipt: HandsReceipt,
192
+ archive: string,
193
+ running: boolean,
194
+ ): Promise<boolean> {
195
+ if (!running || receipt.generation === undefined) {
196
+ return false;
197
+ }
198
+ try {
199
+ await exportHandsWorkspace(root, receipt, archive);
200
+ return true;
201
+ } catch {
202
+ return false;
203
+ }
204
+ }
@@ -0,0 +1,118 @@
1
+ /** Exact Engine, setup label, image and kernel lifetime checks precede every setup finalizer. */
2
+ import { join } from "node:path";
3
+ import { atomicWrite, SeptError } from "../durable/index.js";
4
+ import {
5
+ DockerEngineControl,
6
+ captureNamespaceLifetime,
7
+ stopCapturedNamespace,
8
+ parseNamespaceLifetime,
9
+ type DockerHandsConfiguration,
10
+ } from "../execution/index.js";
11
+ import { handsDocker } from "./hands-docker.js";
12
+ import { readHandsMetadata, writeHandsReceipt, type HandsReceipt } from "./hands-record.js";
13
+
14
+ function isRecord(value: unknown): value is Record<string, unknown> {
15
+ return typeof value === "object" && value !== null && !Array.isArray(value);
16
+ }
17
+
18
+ function record(value: unknown): Record<string, unknown> {
19
+ if (!isRecord(value)) {
20
+ throw new SeptError("Hands setup inspection is malformed");
21
+ }
22
+ return value;
23
+ }
24
+
25
+ /** Open the originally captured socket/daemon only; ambient context and replacement daemons refuse. */
26
+ export async function openHandsEngine(receipt: HandsReceipt): Promise<DockerEngineControl> {
27
+ const engine = await DockerEngineControl.open(receipt.engineSocket);
28
+ if (engine.socketIdentity !== receipt.socketIdentity || engine.daemonId !== receipt.daemonId) {
29
+ await engine.close();
30
+ throw new SeptError("Hands setup Engine ownership changed");
31
+ }
32
+ return engine;
33
+ }
34
+
35
+ /** A pre-create receipt can recover only its unique setup-labelled container, never a foreign name. */
36
+ export async function inspectHandsOwner(
37
+ root: string,
38
+ receipt: HandsReceipt,
39
+ engine: DockerEngineControl,
40
+ ): Promise<Record<string, unknown>> {
41
+ const containerId =
42
+ receipt.containerId ??
43
+ handsDocker(root, receipt, ["inspect", "--format", "{{.Id}}", `sept-hands-${receipt.owner}`]);
44
+ if (!/^[a-f0-9]{64}$/.test(containerId)) {
45
+ throw new SeptError("Hands setup container identity is malformed");
46
+ }
47
+ const inspected = record(await engine.inspect(containerId));
48
+ const labels = record(record(inspected.Config).Labels);
49
+ const state = record(inspected.State);
50
+ if (
51
+ labels["sept.setup.owner"] !== receipt.owner ||
52
+ inspected.Image !== receipt.image ||
53
+ inspected.Id !== containerId ||
54
+ (receipt.generation !== undefined && state.StartedAt !== receipt.generation)
55
+ ) {
56
+ throw new SeptError("Hands setup container ownership or generation changed");
57
+ }
58
+ receipt.containerId = containerId;
59
+ writeHandsReceipt(root, receipt);
60
+ return state;
61
+ }
62
+
63
+ /** Capture the native pinned stop receipt before granting the first permission revision. */
64
+ export async function captureHandsOwner(
65
+ root: string,
66
+ receipt: HandsReceipt,
67
+ configuration: DockerHandsConfiguration,
68
+ engine: DockerEngineControl,
69
+ ): Promise<void> {
70
+ const lifetime = await captureNamespaceLifetime(configuration, engine);
71
+ atomicWrite(join(root, "evidence", "lifetime.json"), lifetime);
72
+ receipt.phase = "running";
73
+ writeHandsReceipt(root, receipt);
74
+ }
75
+
76
+ /** Stop only the captured namespace lifetime, independently verifying stopped/PID0 after helper teardown. */
77
+ export async function stopHandsOwner(
78
+ root: string,
79
+ receipt: HandsReceipt,
80
+ engine: DockerEngineControl,
81
+ ): Promise<void> {
82
+ const state = await inspectHandsOwner(root, receipt, engine);
83
+ if (state.Running === false && state.Pid === 0) {
84
+ return;
85
+ }
86
+ if (receipt.containerId === undefined || receipt.generation === undefined) {
87
+ throw new SeptError("Hands generation was not captured; manual reconciliation required");
88
+ }
89
+ const path = join(root, "evidence", "lifetime.json");
90
+ const raw = readHandsMetadata(path, 1024);
91
+ const lifetime = parseNamespaceLifetime(raw);
92
+ try {
93
+ await stopCapturedNamespace(
94
+ { containerId: receipt.containerId, generation: receipt.generation },
95
+ lifetime,
96
+ engine,
97
+ );
98
+ } catch {
99
+ // A successful pinned namespace stop also tears down its own helper; closure decides success.
100
+ }
101
+ await verifyStopped(root, receipt, engine);
102
+ }
103
+
104
+ async function verifyStopped(
105
+ root: string,
106
+ receipt: HandsReceipt,
107
+ engine: DockerEngineControl,
108
+ ): Promise<void> {
109
+ const deadline = performance.now() + 30_000;
110
+ while (performance.now() < deadline) {
111
+ const state = await inspectHandsOwner(root, receipt, engine);
112
+ if (state.Running === false && state.Pid === 0) {
113
+ return;
114
+ }
115
+ await new Promise<void>((resolve) => setTimeout(resolve, 100));
116
+ }
117
+ throw new SeptError("Hands setup shutdown could not be verified");
118
+ }