sept-agent 0.1.3 → 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.
- package/CONTRIBUTING.md +8 -4
- package/README.md +33 -8
- package/dist/src/cli/commands.js +4 -0
- package/dist/src/cli/commands.js.map +1 -1
- package/dist/src/cli/hands-docker.d.ts +7 -0
- package/dist/src/cli/hands-docker.js +182 -0
- package/dist/src/cli/hands-docker.js.map +1 -0
- package/dist/src/cli/hands-finish.d.ts +2 -0
- package/dist/src/cli/hands-finish.js +164 -0
- package/dist/src/cli/hands-finish.js.map +1 -0
- package/dist/src/cli/hands-owner.d.ts +10 -0
- package/dist/src/cli/hands-owner.js +83 -0
- package/dist/src/cli/hands-owner.js.map +1 -0
- package/dist/src/cli/hands-record.d.ts +33 -0
- package/dist/src/cli/hands-record.js +164 -0
- package/dist/src/cli/hands-record.js.map +1 -0
- package/dist/src/cli/hands.d.ts +3 -0
- package/dist/src/cli/hands.js +169 -0
- package/dist/src/cli/hands.js.map +1 -0
- package/dist/src/cli/options.d.ts +1 -0
- package/dist/src/cli/options.js +2 -1
- package/dist/src/cli/options.js.map +1 -1
- package/dist/src/cli.js +1 -1
- package/dist/src/cli.js.map +1 -1
- package/dist/src/execution/index.d.ts +4 -0
- package/dist/src/execution/index.js +4 -0
- package/dist/src/execution/index.js.map +1 -1
- package/examples/incident-investigator/README.md +4 -4
- package/examples/incident-investigator/package.json +1 -1
- package/examples/node-app/README.md +4 -4
- package/examples/node-app/package.json +1 -1
- package/package.json +1 -8
- package/src/cli/commands.ts +4 -0
- package/src/cli/hands-docker.ts +198 -0
- package/src/cli/hands-finish.ts +204 -0
- package/src/cli/hands-owner.ts +118 -0
- package/src/cli/hands-record.ts +213 -0
- package/src/cli/hands.ts +207 -0
- package/src/cli/options.ts +3 -1
- package/src/cli.ts +1 -1
- package/src/execution/index.ts +10 -0
- package/docs/architecture.md +0 -168
- package/docs/index.md +0 -55
- package/docs/models.md +0 -78
- package/docs/observability.md +0 -63
- package/docs/pi-contract.md +0 -482
- package/docs/sdk.md +0 -180
- package/docs/worker-api.md +0 -138
|
@@ -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 [
|
|
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
|
|
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.
|
|
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.
|
|
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
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "sept-agent",
|
|
3
|
-
"version": "0.1.
|
|
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",
|
package/src/cli/commands.ts
CHANGED
|
@@ -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
|
+
}
|