pi-session-mcp 0.11.9 → 0.12.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.
- package/.pi-session-mcp-cli/client-adapters/mcp-verifier.js +6 -2
- package/.pi-session-mcp-cli/doctor.js +89 -4
- package/.pi-session-mcp-cli/execution-modes.js +19 -5
- package/.pi-session-mcp-cli/override-lock.js +4 -4
- package/.pi-session-mcp-cli/setup-process.js +5 -4
- package/.pi-session-mcp-cli/writer-boundary.js +10 -4
- package/.pi-session-mcp-cli/writer-fence.js +50 -0
- package/.pi-session-mcp-cli/writer-lease.js +140 -0
- package/.pi-session-mcp-cli/writer-lifecycle.js +55 -23
- package/.pi-session-mcp-cli/writer-state.js +4 -2
- package/CHANGELOG.md +91 -0
- package/README.md +3 -2
- package/dist/capabilities.d.ts +2 -0
- package/dist/capabilities.js +4 -1
- package/dist/child-stdio-transport.d.ts +21 -0
- package/dist/child-stdio-transport.js +88 -0
- package/dist/client-adapters/mcp-verifier.js +6 -2
- package/dist/client-adapters/types.d.ts +1 -0
- package/dist/doctor.d.ts +3 -1
- package/dist/doctor.js +89 -4
- package/dist/execution-modes.d.ts +14 -2
- package/dist/execution-modes.js +19 -5
- package/dist/main.js +9 -2
- package/dist/override-lock.d.ts +2 -0
- package/dist/override-lock.js +4 -4
- package/dist/pi-adapter.d.ts +30 -0
- package/dist/pi-adapter.js +11 -0
- package/dist/refusal-server.d.ts +1 -1
- package/dist/refusal-server.js +24 -2
- package/dist/server.d.ts +2 -2
- package/dist/server.js +18 -53
- package/dist/session-registry.d.ts +9 -2
- package/dist/session-registry.js +17 -12
- package/dist/setup-cli.js +7 -2
- package/dist/setup-hooks.js +0 -2
- package/dist/setup-modes.js +7 -1
- package/dist/setup-process.d.ts +3 -1
- package/dist/setup-process.js +5 -4
- package/dist/setup-writers.d.ts +5 -0
- package/dist/setup-writers.js +203 -94
- package/dist/tool-schemas.d.ts +339 -0
- package/dist/tool-schemas.js +49 -0
- package/dist/wake-hook.d.ts +4 -5
- package/dist/wake-hook.js +3 -6
- package/dist/writer-acceptance.js +10 -1
- package/dist/writer-boundary.d.ts +6 -4
- package/dist/writer-boundary.js +10 -4
- package/dist/writer-fence.d.ts +12 -0
- package/dist/writer-fence.js +50 -0
- package/dist/writer-launch.d.ts +9 -1
- package/dist/writer-launch.js +127 -30
- package/dist/writer-lease.d.ts +28 -0
- package/dist/writer-lease.js +140 -0
- package/dist/writer-lifecycle.d.ts +7 -1
- package/dist/writer-lifecycle.js +55 -23
- package/dist/writer-relay.d.ts +33 -0
- package/dist/writer-relay.js +359 -0
- package/dist/writer-state.d.ts +13 -1
- package/dist/writer-state.js +4 -2
- package/docs/acceptance.md +172 -0
- package/docs/adr/0012-leased-writer-pool.md +357 -0
- package/docs/architecture.md +61 -13
- package/docs/client-setup.md +11 -15
- package/docs/doctor-design.md +10 -4
- package/docs/execution-modes.md +87 -44
- package/docs/installation.md +43 -22
- package/docs/isolation-setup.md +88 -102
- package/docs/roadmap.md +6 -0
- package/docs/threat-model.md +7 -4
- package/docs/tool-contracts.md +29 -12
- package/package.json +1 -1
- package/scripts/isolation-acceptance.mjs +7 -1
- package/skills/pi-session-implementation/SKILL.md +1 -0
- package/skills/pi-session-orchestration/SKILL.md +6 -5
- package/skills/pi-session-orchestration/references/start-errors.md +5 -1
package/dist/writer-launch.js
CHANGED
|
@@ -5,12 +5,15 @@ import { dirname, join, resolve } from "node:path";
|
|
|
5
5
|
import { fileURLToPath } from "node:url";
|
|
6
6
|
import { isEntryPoint } from "./entry-point.js";
|
|
7
7
|
import { serveRefusal } from "./refusal-server.js";
|
|
8
|
+
import { acquireWriterLease, adoptInheritedLease, WriterLeaseError } from "./writer-lease.js";
|
|
9
|
+
import { ensureWriterAbsent, writerContainerName } from "./writer-fence.js";
|
|
8
10
|
import { createProcessRunner } from "./setup-process.js";
|
|
9
11
|
import { ensureWriterEventsDirectory, podmanInfo, serveArgs } from "./writer-boundary.js";
|
|
10
12
|
import { expiresWithin, REFRESH_INTERVAL_MS, REFRESH_THRESHOLD_MINUTES, refreshHostCredential, selectedOAuthProviders, selectBoundaryCredentials, writeBoundaryCredentials } from "./writer-credentials.js";
|
|
11
13
|
import { currentAcceptanceFingerprint, readWriterState, WRITER_NAME_PATTERN, writerEventDeliveryEnabled, writerPaths } from "./writer-state.js";
|
|
12
14
|
export { currentAcceptanceFingerprint } from "./writer-state.js";
|
|
13
15
|
export { renderWrapper, wrapperDigest } from "./writer-wrapper.js";
|
|
16
|
+
export const LAUNCHER_CLEANUP_BOUND_MS = 40_000;
|
|
14
17
|
function launchFailure(stage) {
|
|
15
18
|
process.stderr.write(JSON.stringify({ level: "error", event: "writer_launch_failed", stage, code: "writer_launch_failed" }) + "\n");
|
|
16
19
|
}
|
|
@@ -27,10 +30,35 @@ export async function launchEntry(argv, deps) {
|
|
|
27
30
|
}
|
|
28
31
|
}
|
|
29
32
|
export async function launch(argv, deps) {
|
|
30
|
-
const refuse = async (code) => { await serveRefusal(code); return 0; };
|
|
31
33
|
const name = argv[0];
|
|
32
|
-
if (!name || !WRITER_NAME_PATTERN.test(name))
|
|
33
|
-
|
|
34
|
+
if (!name || !WRITER_NAME_PATTERN.test(name)) {
|
|
35
|
+
await serveRefusal("writer_not_ready");
|
|
36
|
+
return 0;
|
|
37
|
+
}
|
|
38
|
+
const inherited = deps.env.PI_WRITER_LEASE_FD;
|
|
39
|
+
if (inherited !== undefined && (!Number.isSafeInteger(Number(inherited)) || Number(inherited) < 0 || String(Number(inherited)) !== inherited)) {
|
|
40
|
+
await serveRefusal("writer_not_ready");
|
|
41
|
+
return 0;
|
|
42
|
+
}
|
|
43
|
+
let lease;
|
|
44
|
+
try {
|
|
45
|
+
lease = inherited === undefined
|
|
46
|
+
? await (deps.lease?.acquire ?? acquireWriterLease)(deps.home, name, { waitMs: 0 })
|
|
47
|
+
: await (deps.lease?.adopt ?? adoptInheritedLease)(deps.home, name, Number(inherited));
|
|
48
|
+
}
|
|
49
|
+
catch (error) {
|
|
50
|
+
await serveRefusal(error instanceof WriterLeaseError && error.code === "writer_busy" ? "writer_busy" : "writer_not_ready");
|
|
51
|
+
return 0;
|
|
52
|
+
}
|
|
53
|
+
try {
|
|
54
|
+
return await launchLeased(argv, deps, name, lease);
|
|
55
|
+
}
|
|
56
|
+
finally {
|
|
57
|
+
await lease.release();
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
async function launchLeased(argv, deps, name, lease) {
|
|
61
|
+
const refuse = async (code) => { await serveRefusal(code); return 0; };
|
|
34
62
|
const info = await podmanInfo(deps.runner);
|
|
35
63
|
if (!info.usable || !info.version)
|
|
36
64
|
return refuse("container_runtime_unavailable");
|
|
@@ -48,8 +76,15 @@ export async function launch(argv, deps) {
|
|
|
48
76
|
catch {
|
|
49
77
|
return refuse("writer_not_ready");
|
|
50
78
|
}
|
|
79
|
+
const ensureAbsent = () => (deps.ensureAbsent ?? ensureWriterAbsent)(deps.runner, paths, name, { leaseFd: lease.fd });
|
|
80
|
+
try {
|
|
81
|
+
await ensureAbsent();
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
return refuse("writer_fenced");
|
|
85
|
+
}
|
|
51
86
|
let eventsDir;
|
|
52
|
-
if (await writerEventDeliveryEnabled(paths.config)) {
|
|
87
|
+
if (state.launch !== "relay" && await writerEventDeliveryEnabled(paths.config)) {
|
|
53
88
|
try {
|
|
54
89
|
eventsDir = await ensureWriterEventsDirectory(deps.home, name, (deps.uid ?? (() => process.getuid()))());
|
|
55
90
|
}
|
|
@@ -90,46 +125,108 @@ export async function launch(argv, deps) {
|
|
|
90
125
|
const command = argv.length > 1 ? argv.slice(1) : ["node", "/opt/pi-session-mcp/dist/main.js"];
|
|
91
126
|
let child;
|
|
92
127
|
try {
|
|
93
|
-
child = deps.spawnPodman(serveArgs(paths, state, command, deps.releaseRoot, eventsDir));
|
|
128
|
+
child = deps.spawnPodman(serveArgs(paths, state, command, deps.releaseRoot, eventsDir, { name }), [lease.fd]);
|
|
94
129
|
}
|
|
95
130
|
catch {
|
|
96
131
|
launchFailure("spawn");
|
|
132
|
+
await ensureAbsent();
|
|
97
133
|
return 1;
|
|
98
134
|
}
|
|
99
|
-
let
|
|
135
|
+
let outstanding;
|
|
136
|
+
let stopping = false;
|
|
100
137
|
const timer = enabled ? deps.setInterval(() => {
|
|
101
|
-
if (
|
|
138
|
+
if (stopping || outstanding)
|
|
102
139
|
return;
|
|
103
|
-
|
|
104
|
-
void refresh().catch(() => {
|
|
140
|
+
outstanding = refresh().catch(() => {
|
|
105
141
|
process.stderr.write(JSON.stringify({ level: "warning", event: "writer_refresh_failed", code: "writer_refresh_failed" }) + "\n");
|
|
106
|
-
}).finally(() => {
|
|
142
|
+
}).finally(() => { outstanding = undefined; });
|
|
107
143
|
}, REFRESH_INTERVAL_MS) : undefined;
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
144
|
+
let closed = false;
|
|
145
|
+
let exitCode = 1;
|
|
146
|
+
let spawnFailed = false;
|
|
147
|
+
let resolveClosed;
|
|
148
|
+
const childClosed = new Promise((resolve) => { resolveClosed = resolve; });
|
|
149
|
+
let cleanup;
|
|
150
|
+
let resolveExit;
|
|
151
|
+
let rejectExit;
|
|
152
|
+
const finished = new Promise((resolve, reject) => { resolveExit = resolve; rejectExit = reject; });
|
|
153
|
+
const stop = (signal) => {
|
|
154
|
+
if (cleanup)
|
|
155
|
+
return cleanup;
|
|
156
|
+
stopping = true;
|
|
157
|
+
if (timer !== undefined)
|
|
158
|
+
deps.clearInterval(timer);
|
|
159
|
+
cleanup = (async () => {
|
|
160
|
+
let timeout;
|
|
161
|
+
const bounded = new Promise((resolve) => { timeout = setTimeout(resolve, LAUNCHER_CLEANUP_BOUND_MS); });
|
|
162
|
+
try {
|
|
163
|
+
await outstanding;
|
|
164
|
+
await Promise.race([childClosed, bounded]);
|
|
165
|
+
if (!closed) {
|
|
166
|
+
await deps.runner.run("podman", ["rm", "-f", "-t", "0", writerContainerName(name)], AbortSignal.timeout(20_000), { inheritFds: [lease.fd] }).catch(() => undefined);
|
|
167
|
+
}
|
|
168
|
+
await ensureAbsent();
|
|
169
|
+
return exitCode;
|
|
170
|
+
}
|
|
171
|
+
finally {
|
|
172
|
+
clearTimeout(timeout);
|
|
173
|
+
process.off("SIGINT", interrupt);
|
|
174
|
+
process.off("SIGTERM", terminate);
|
|
175
|
+
process.off("SIGHUP", terminate);
|
|
176
|
+
process.stdin.off("end", endOfInput);
|
|
177
|
+
if (child.stdin) {
|
|
178
|
+
process.stdin.unpipe(child.stdin);
|
|
179
|
+
child.stdin.destroy();
|
|
180
|
+
process.stdin.pause();
|
|
181
|
+
}
|
|
182
|
+
// A wedged Podman client still retains its inherited lease, but must not pin this launcher.
|
|
183
|
+
if (!closed)
|
|
184
|
+
child.unref?.();
|
|
185
|
+
}
|
|
186
|
+
})();
|
|
187
|
+
void cleanup.then(resolveExit, rejectExit);
|
|
188
|
+
// Publish the shared cleanup promise before forwarding a signal can produce close.
|
|
189
|
+
if (signal && !closed)
|
|
190
|
+
child.kill(signal);
|
|
191
|
+
return cleanup;
|
|
192
|
+
};
|
|
193
|
+
const interrupt = () => { void stop("SIGINT"); };
|
|
194
|
+
const terminate = () => { void stop("SIGTERM"); };
|
|
195
|
+
// End of input is passed on through the pipe, not as a signal: a relayed server shuts down on EOF and a
|
|
196
|
+
// finite acceptance probe needs EOF to run at all. Cleanup starts and waits, bounded, for the child to close.
|
|
197
|
+
const endOfInput = () => { void stop(); };
|
|
198
|
+
process.on("SIGINT", interrupt);
|
|
199
|
+
process.on("SIGTERM", terminate);
|
|
200
|
+
process.on("SIGHUP", terminate);
|
|
201
|
+
process.stdin.once("end", endOfInput);
|
|
202
|
+
{
|
|
203
|
+
// Signal/EOF cleanup and child-exit cleanup share exactly one completion.
|
|
204
|
+
const complete = () => { void stop(); };
|
|
205
|
+
const onClose = (code, signal) => {
|
|
206
|
+
closed = true;
|
|
207
|
+
exitCode = spawnFailed ? 1 : code ?? (signal === "SIGINT" ? 130 : signal === "SIGTERM" ? 143 : 1);
|
|
208
|
+
resolveClosed();
|
|
209
|
+
complete();
|
|
121
210
|
};
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
211
|
+
child.once("error", () => { spawnFailed = true; launchFailure("spawn"); complete(); });
|
|
212
|
+
child.once("close", onClose);
|
|
213
|
+
// Attach before piping: EOF can arrive immediately, and must not steal MCP input.
|
|
214
|
+
if (child.stdin) {
|
|
215
|
+
// Podman can close its pipe before the host notices; cleanup still owns the fence.
|
|
216
|
+
child.stdin.once("error", terminate);
|
|
217
|
+
process.stdin.pipe(child.stdin);
|
|
218
|
+
}
|
|
219
|
+
if (process.stdin.readableEnded) {
|
|
220
|
+
child.stdin?.end();
|
|
221
|
+
endOfInput();
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
return finished;
|
|
128
225
|
}
|
|
129
226
|
if (isEntryPoint(import.meta.url, process.argv[1])) {
|
|
130
227
|
void launchEntry(process.argv.slice(2), {
|
|
131
228
|
home: homedir(), releaseRoot: resolve(dirname(fileURLToPath(import.meta.url)), ".."),
|
|
132
|
-
runner: createProcessRunner(), spawnPodman: (args) => spawn("podman", args, { stdio: "inherit" }),
|
|
229
|
+
runner: createProcessRunner(), spawnPodman: (args, inheritFds) => spawn("podman", args, { stdio: ["pipe", "inherit", "inherit", ...inheritFds] }),
|
|
133
230
|
env: process.env, now: Date.now, setInterval, clearInterval,
|
|
134
231
|
}).then((code) => { process.exitCode = code; });
|
|
135
232
|
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { type StatfsReader } from "./setup-platform.js";
|
|
2
|
+
export declare const LEASE_DIRECTORY: readonly [".local", "state", "pi-session-mcp", "leases"];
|
|
3
|
+
export declare class WriterLeaseError extends Error {
|
|
4
|
+
readonly code: "writer_busy" | "writer_lease_unavailable";
|
|
5
|
+
constructor(code: "writer_busy" | "writer_lease_unavailable");
|
|
6
|
+
}
|
|
7
|
+
export interface WriterLease {
|
|
8
|
+
readonly fd: number;
|
|
9
|
+
release(): Promise<void>;
|
|
10
|
+
}
|
|
11
|
+
interface LeaseSeams {
|
|
12
|
+
readonly signal?: AbortSignal;
|
|
13
|
+
readonly flockCandidates?: readonly string[];
|
|
14
|
+
readonly isTrustedBinary?: (path: string) => boolean;
|
|
15
|
+
readonly readStatfs?: StatfsReader;
|
|
16
|
+
}
|
|
17
|
+
export declare function writerLeasePath(home: string, name: string): string;
|
|
18
|
+
export declare function acquireWriterLease(home: string, name: string, options: LeaseSeams & {
|
|
19
|
+
waitMs: number;
|
|
20
|
+
}): Promise<WriterLease>;
|
|
21
|
+
/**
|
|
22
|
+
* Adopts a descriptor a parent passed down while it holds the lease. A fresh open must find the
|
|
23
|
+
* lock busy before the descriptor is touched; if the holder released in between, the descriptor's
|
|
24
|
+
* own `flock -n` takes the lock instead, which still leaves exactly one exclusive holder. Adopt the
|
|
25
|
+
* same descriptor once: `release()` closes that descriptor number.
|
|
26
|
+
*/
|
|
27
|
+
export declare function adoptInheritedLease(home: string, name: string, fd: number, options?: LeaseSeams): Promise<WriterLease>;
|
|
28
|
+
export {};
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { close, constants, fstat } from "node:fs";
|
|
2
|
+
import { chmod, lstat, mkdir, open } from "node:fs/promises";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { promisify } from "node:util";
|
|
5
|
+
import { flockBinary, lockWithFlock, OverrideLockError } from "./override-lock.js";
|
|
6
|
+
import { assertSupportedFilesystem } from "./setup-platform.js";
|
|
7
|
+
import { WRITER_NAME_PATTERN } from "./writer-state.js";
|
|
8
|
+
export const LEASE_DIRECTORY = [".local", "state", "pi-session-mcp", "leases"];
|
|
9
|
+
export class WriterLeaseError extends Error {
|
|
10
|
+
code;
|
|
11
|
+
constructor(code) {
|
|
12
|
+
super("Writer lease is not available");
|
|
13
|
+
this.code = code;
|
|
14
|
+
this.name = "WriterLeaseError";
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
export function writerLeasePath(home, name) {
|
|
18
|
+
if (!WRITER_NAME_PATTERN.test(name))
|
|
19
|
+
throw new WriterLeaseError("writer_lease_unavailable");
|
|
20
|
+
return join(home, ...LEASE_DIRECTORY, `${name}.lock`);
|
|
21
|
+
}
|
|
22
|
+
function checkFile(file) {
|
|
23
|
+
if (!file.isFile() || file.uid !== process.getuid() || (file.mode & 0o7777) !== 0o600) {
|
|
24
|
+
throw new WriterLeaseError("writer_lease_unavailable");
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
async function checkPath(path, descriptor) {
|
|
28
|
+
checkFile(descriptor);
|
|
29
|
+
const linked = await lstat(path);
|
|
30
|
+
checkFile(linked);
|
|
31
|
+
if (descriptor.dev !== linked.dev || descriptor.ino !== linked.ino)
|
|
32
|
+
throw new WriterLeaseError("writer_lease_unavailable");
|
|
33
|
+
}
|
|
34
|
+
async function prepare(home, name, options, create) {
|
|
35
|
+
if (process.platform !== "linux" || options.signal?.aborted)
|
|
36
|
+
throw new WriterLeaseError("writer_lease_unavailable");
|
|
37
|
+
const path = writerLeasePath(home, name);
|
|
38
|
+
const binary = flockBinary(options);
|
|
39
|
+
if (!binary)
|
|
40
|
+
throw new WriterLeaseError("writer_lease_unavailable");
|
|
41
|
+
const directory = join(home, ...LEASE_DIRECTORY);
|
|
42
|
+
// The umask may narrow the requested modes; a directory this call created gets its exact mode.
|
|
43
|
+
if (create && await mkdir(directory, { recursive: true, mode: 0o700 }) !== undefined)
|
|
44
|
+
await chmod(directory, 0o700);
|
|
45
|
+
const info = await lstat(directory);
|
|
46
|
+
if (!info.isDirectory() || info.uid !== process.getuid() || (info.mode & 0o7777) !== 0o700)
|
|
47
|
+
throw new WriterLeaseError("writer_lease_unavailable");
|
|
48
|
+
await assertSupportedFilesystem(directory, "linux", options.readStatfs);
|
|
49
|
+
return { path, binary };
|
|
50
|
+
}
|
|
51
|
+
/** Creates the file exclusively and sets its exact mode, or opens the existing one; never follows a link. */
|
|
52
|
+
async function openLeaseFile(path) {
|
|
53
|
+
try {
|
|
54
|
+
const created = await open(path, constants.O_NOFOLLOW | constants.O_CREAT | constants.O_EXCL | constants.O_RDWR, 0o600);
|
|
55
|
+
try {
|
|
56
|
+
await created.chmod(0o600);
|
|
57
|
+
}
|
|
58
|
+
catch (error) {
|
|
59
|
+
await created.close().catch(() => undefined);
|
|
60
|
+
throw error;
|
|
61
|
+
}
|
|
62
|
+
return created;
|
|
63
|
+
}
|
|
64
|
+
catch (error) {
|
|
65
|
+
if (error.code !== "EEXIST")
|
|
66
|
+
throw error;
|
|
67
|
+
return open(path, constants.O_NOFOLLOW | constants.O_RDWR);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
function lease(fd, closeDescriptor) {
|
|
71
|
+
let released = false;
|
|
72
|
+
return {
|
|
73
|
+
fd,
|
|
74
|
+
async release() {
|
|
75
|
+
if (released)
|
|
76
|
+
return;
|
|
77
|
+
released = true;
|
|
78
|
+
// Never unlock explicitly: mutating children can still hold this open file description.
|
|
79
|
+
await closeDescriptor().catch(() => undefined);
|
|
80
|
+
},
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
export async function acquireWriterLease(home, name, options) {
|
|
84
|
+
let handle;
|
|
85
|
+
try {
|
|
86
|
+
if (!Number.isFinite(options.waitMs) || options.waitMs < 0)
|
|
87
|
+
throw new WriterLeaseError("writer_lease_unavailable");
|
|
88
|
+
const { path, binary } = await prepare(home, name, options, true);
|
|
89
|
+
handle = await openLeaseFile(path);
|
|
90
|
+
checkFile(await handle.stat());
|
|
91
|
+
await lockWithFlock(binary, handle.fd, options.waitMs, options.signal, options.waitMs === 0);
|
|
92
|
+
await checkPath(path, await handle.stat());
|
|
93
|
+
const held = handle;
|
|
94
|
+
return lease(held.fd, () => held.close());
|
|
95
|
+
}
|
|
96
|
+
catch (error) {
|
|
97
|
+
await handle?.close().catch(() => undefined);
|
|
98
|
+
throw new WriterLeaseError(error instanceof OverrideLockError && error.code === "override_lock_busy" ? "writer_busy" : "writer_lease_unavailable");
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Adopts a descriptor a parent passed down while it holds the lease. A fresh open must find the
|
|
103
|
+
* lock busy before the descriptor is touched; if the holder released in between, the descriptor's
|
|
104
|
+
* own `flock -n` takes the lock instead, which still leaves exactly one exclusive holder. Adopt the
|
|
105
|
+
* same descriptor once: `release()` closes that descriptor number.
|
|
106
|
+
*/
|
|
107
|
+
export async function adoptInheritedLease(home, name, fd, options = {}) {
|
|
108
|
+
let probe;
|
|
109
|
+
try {
|
|
110
|
+
if (!Number.isInteger(fd) || fd < 0)
|
|
111
|
+
throw new WriterLeaseError("writer_lease_unavailable");
|
|
112
|
+
const { path, binary } = await prepare(home, name, options, false);
|
|
113
|
+
await checkPath(path, await promisify(fstat)(fd));
|
|
114
|
+
probe = await open(path, constants.O_NOFOLLOW | constants.O_RDWR);
|
|
115
|
+
await checkPath(path, await probe.stat());
|
|
116
|
+
// A fresh open must be excluded BEFORE touching the inherited description; otherwise
|
|
117
|
+
// flock on an unlocked inherited fd would itself acquire a lock and falsely prove ownership.
|
|
118
|
+
let busy = false;
|
|
119
|
+
try {
|
|
120
|
+
await lockWithFlock(binary, probe.fd, 0, options.signal, true);
|
|
121
|
+
}
|
|
122
|
+
catch (error) {
|
|
123
|
+
if (!(error instanceof OverrideLockError) || error.code !== "override_lock_busy")
|
|
124
|
+
throw error;
|
|
125
|
+
busy = true;
|
|
126
|
+
}
|
|
127
|
+
if (!busy)
|
|
128
|
+
throw new WriterLeaseError("writer_lease_unavailable");
|
|
129
|
+
await lockWithFlock(binary, fd, 0, options.signal, true);
|
|
130
|
+
await checkPath(path, await promisify(fstat)(fd));
|
|
131
|
+
return lease(fd, () => promisify(close)(fd));
|
|
132
|
+
}
|
|
133
|
+
catch {
|
|
134
|
+
// Rejection does not take ownership of the caller's descriptor.
|
|
135
|
+
throw new WriterLeaseError("writer_lease_unavailable");
|
|
136
|
+
}
|
|
137
|
+
finally {
|
|
138
|
+
await probe?.close().catch(() => undefined);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
@@ -7,6 +7,7 @@ export interface WriterContext {
|
|
|
7
7
|
runner: CommandRunner;
|
|
8
8
|
checkout: string;
|
|
9
9
|
alias: string;
|
|
10
|
+
leaseFd?: number;
|
|
10
11
|
}
|
|
11
12
|
export type WriterOutcome = {
|
|
12
13
|
code: string;
|
|
@@ -37,6 +38,8 @@ export declare function renderContainedConfig(alias: string, clonePath: string,
|
|
|
37
38
|
skills?: string[] | undefined;
|
|
38
39
|
}>;
|
|
39
40
|
};
|
|
41
|
+
/** The short branch name provisioning would record for `requested` (or its default base), normalized like the writer state. */
|
|
42
|
+
export declare function baseBranchName(ctx: WriterContext, requested?: string): Promise<string>;
|
|
40
43
|
type ProvisionInput = {
|
|
41
44
|
settings: ContainerSettings;
|
|
42
45
|
rawConfig: unknown;
|
|
@@ -78,12 +81,15 @@ export type CollectOutcome = WriterOutcome & {
|
|
|
78
81
|
export declare function collectWriter(ctx: WriterContext, name: string): Promise<CollectOutcome>;
|
|
79
82
|
export declare function recreateWriter(ctx: WriterContext, name: string, opts: {
|
|
80
83
|
discard: boolean;
|
|
84
|
+
baseRef?: string;
|
|
81
85
|
}): Promise<WriterOutcome>;
|
|
82
86
|
export declare function resetWriter(ctx: WriterContext, name: string, opts: {
|
|
83
87
|
discard: boolean;
|
|
84
88
|
baseRef?: string;
|
|
85
89
|
}): Promise<WriterOutcome>;
|
|
86
|
-
export declare function finishWriter(ctx: WriterContext, name: string
|
|
90
|
+
export declare function finishWriter(ctx: WriterContext, name: string, opts?: {
|
|
91
|
+
baseRef?: string;
|
|
92
|
+
}): Promise<WriterOutcome>;
|
|
87
93
|
export declare function removeWriter(ctx: WriterContext, name: string, opts: {
|
|
88
94
|
discard: boolean;
|
|
89
95
|
}): Promise<WriterOutcome>;
|
package/dist/writer-lifecycle.js
CHANGED
|
@@ -1,13 +1,19 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
2
|
import { constants } from "node:fs";
|
|
3
3
|
import { chmod, mkdir, open, readdir, rm, writeFile } from "node:fs/promises";
|
|
4
|
-
import { join } from "node:path";
|
|
4
|
+
import { basename, join } from "node:path";
|
|
5
5
|
import { configSchema } from "./config.js";
|
|
6
6
|
import { renderWrapper } from "./writer-wrapper.js";
|
|
7
7
|
import { builtinSkillName } from "./skill-catalog.js";
|
|
8
8
|
import { resolveLibrarySkill, shippedLibrarySkill } from "./skill-library.js";
|
|
9
9
|
import { BOUNDARY_GIT, BoundaryError, IMAGE_PULL_TIMEOUT_MS, isSafeRefName, podmanInfo, resolveImage, throwawayArgs, verifyCloneLayout } from "./writer-boundary.js";
|
|
10
10
|
import { readWriterState, writerName, writerPaths, writeWriterState, WriterStateError } from "./writer-state.js";
|
|
11
|
+
function leasedContext(ctx) {
|
|
12
|
+
if (ctx.leaseFd === undefined)
|
|
13
|
+
return ctx;
|
|
14
|
+
const runner = ctx.runner;
|
|
15
|
+
return { ...ctx, runner: { run: (command, args, signal, options) => runner.run(command, args, signal, { ...options, inheritFds: [ctx.leaseFd] }) } };
|
|
16
|
+
}
|
|
11
17
|
class LifecycleError extends Error {
|
|
12
18
|
code;
|
|
13
19
|
constructor(code) {
|
|
@@ -100,13 +106,23 @@ function buildContainedConfig(alias, clonePath, rawConfig, selected, availableBu
|
|
|
100
106
|
}
|
|
101
107
|
// CommandRunner has no per-call environment option. Hooks are disabled even for injected runners.
|
|
102
108
|
async function git(ctx, args, optional = false) {
|
|
109
|
+
ctx = leasedContext(ctx);
|
|
103
110
|
const result = await ctx.runner.run("git", ["-c", "core.hooksPath=/dev/null", "-C", ctx.checkout, ...args], AbortSignal.timeout(IMAGE_PULL_TIMEOUT_MS));
|
|
104
111
|
if (result.exitCode !== 0) {
|
|
105
112
|
if (optional)
|
|
106
113
|
return undefined;
|
|
107
114
|
throw new LifecycleError("writer_base_unavailable");
|
|
108
115
|
}
|
|
109
|
-
return result.stdout.trim();
|
|
116
|
+
return args[0] === "config" ? result.stdout.replace(/\n$/, "") : result.stdout.trim();
|
|
117
|
+
}
|
|
118
|
+
async function operatorIdentity(ctx) {
|
|
119
|
+
const name = await git(ctx, ["config", "--get", "user.name"], true);
|
|
120
|
+
const email = await git(ctx, ["config", "--get", "user.email"], true);
|
|
121
|
+
const valid = (value) => value !== undefined && value.length >= 1 && value.length <= 256 && !value.startsWith("-") && !/[\p{Cc}]/u.test(value);
|
|
122
|
+
return valid(name) && valid(email) ? [name, email] : ["", ""];
|
|
123
|
+
}
|
|
124
|
+
function identityScript(name, email) {
|
|
125
|
+
return `if [ -n "$${name}" ] && [ -n "$${email}" ]; then\n${BOUNDARY_GIT} config user.name "$${name}"\n${BOUNDARY_GIT} config user.email "$${email}"\nelse\n${BOUNDARY_GIT} config --unset-all user.name || :\n${BOUNDARY_GIT} config --unset-all user.email || :\nfi`;
|
|
110
126
|
}
|
|
111
127
|
async function collectGit(ctx, args) {
|
|
112
128
|
try {
|
|
@@ -143,15 +159,21 @@ async function resolveBase(ctx, requested) {
|
|
|
143
159
|
throw new LifecycleError("writer_base_unavailable");
|
|
144
160
|
return { ref, short, commit };
|
|
145
161
|
}
|
|
146
|
-
|
|
162
|
+
/** The short branch name provisioning would record for `requested` (or its default base), normalized like the writer state. */
|
|
163
|
+
export async function baseBranchName(ctx, requested) {
|
|
164
|
+
return (await resolveBase(ctx, requested)).short;
|
|
165
|
+
}
|
|
166
|
+
async function baseBundle(ctx, paths, ref, commit, tags) {
|
|
167
|
+
// for-each-ref prints one full ref per line; `git tag` honours column.ui/column.tag and can print several per line.
|
|
168
|
+
const reachable = tags === "reachable" ? (await git(ctx, ["for-each-ref", "--format=%(refname)", "--merged", commit, "refs/tags"]))?.split("\n").filter((ref) => ref.startsWith("refs/tags/")) ?? [] : [];
|
|
147
169
|
await rm(join(paths.bundles, "base.bundle"), { force: true });
|
|
148
|
-
await git(ctx, ["bundle", "create", join(paths.bundles, "base.bundle"), ref]);
|
|
170
|
+
await git(ctx, ["bundle", "create", join(paths.bundles, "base.bundle"), ref, ...reachable]);
|
|
149
171
|
await chmod(join(paths.bundles, "base.bundle"), 0o600);
|
|
150
172
|
}
|
|
151
|
-
async function cloneFromBundle(ctx, paths, imageId, base, remote) {
|
|
173
|
+
async function cloneFromBundle(ctx, paths, imageId, base, remote, tags) {
|
|
152
174
|
// Clone only auto-fetches refs/heads; fetch the exact bundled ref before checkout.
|
|
153
|
-
const script = `set -eu\nexport GIT_CONFIG_NOSYSTEM=1\n${BOUNDARY_GIT} clone --no-hardlinks /bundles/base.bundle "$1"\ncd "$1"\n${BOUNDARY_GIT} fetch --no-tags /bundles/base.bundle "$5"\n${BOUNDARY_GIT} checkout -B "$2" "$3"\nif [ -n "$4" ]; then ${BOUNDARY_GIT} remote set-url origin "$4"; else ${BOUNDARY_GIT} remote remove origin; fi`;
|
|
154
|
-
const clone = await ctx.runner.run("podman", throwawayArgs(paths, imageId, { clone: "rw", bundles: true }, script, [paths.clone, base.short, base.commit, remote ?? "", base.ref]), AbortSignal.timeout(IMAGE_PULL_TIMEOUT_MS));
|
|
175
|
+
const script = `set -eu\nexport GIT_CONFIG_NOSYSTEM=1\n${BOUNDARY_GIT} clone --no-hardlinks /bundles/base.bundle "$1"\ncd "$1"\n${BOUNDARY_GIT} fetch --no-tags /bundles/base.bundle "$5"\n${BOUNDARY_GIT} checkout -B "$2" "$3"\nif [ -n "$4" ]; then ${BOUNDARY_GIT} remote set-url origin "$4"; else ${BOUNDARY_GIT} remote remove origin; fi\n${identityScript(6, 7)}\n${tags === "reachable" ? `${BOUNDARY_GIT} fetch --no-tags /bundles/base.bundle '+refs/tags/*:refs/tags/*'` : ""}`;
|
|
176
|
+
const clone = await ctx.runner.run("podman", throwawayArgs(basename(paths.root), paths, imageId, { clone: "rw", bundles: true }, script, [paths.clone, base.short, base.commit, remote ?? "", base.ref, ...await operatorIdentity(ctx)]), AbortSignal.timeout(IMAGE_PULL_TIMEOUT_MS));
|
|
155
177
|
if (clone.exitCode !== 0)
|
|
156
178
|
throw new LifecycleError("writer_clone_failed");
|
|
157
179
|
await verifyCloneLayout(paths.clone);
|
|
@@ -198,7 +220,7 @@ async function storedConfig(paths) {
|
|
|
198
220
|
async function sameSettings(paths, existing, settings, now) {
|
|
199
221
|
const stored = await storedConfig(paths);
|
|
200
222
|
return stored !== undefined && existing.imageId === now.imageId && existing.lifecycle === settings.lifecycle
|
|
201
|
-
&& existing.network === settings.network && existing.cache === settings.cache
|
|
223
|
+
&& existing.network === settings.network && existing.cache === settings.cache && existing.tags === settings.tags
|
|
202
224
|
&& JSON.stringify(existing.profiles) === JSON.stringify(settings.profiles)
|
|
203
225
|
&& JSON.stringify(existing.providers) === JSON.stringify(now.providers) && canonical(withoutDeliveryOff(stored)) === canonical(withoutDeliveryOff(now.config));
|
|
204
226
|
}
|
|
@@ -217,6 +239,7 @@ function withoutDeliveryOff(config) {
|
|
|
217
239
|
* answers but cannot resolve the image.
|
|
218
240
|
*/
|
|
219
241
|
export async function writerSettingsDrift(ctx, input) {
|
|
242
|
+
ctx = leasedContext(ctx);
|
|
220
243
|
try {
|
|
221
244
|
const name = writerName(ctx.alias, input.index), paths = writerPaths(ctx.home, name);
|
|
222
245
|
const existing = await readWriterState(ctx.home, name);
|
|
@@ -233,6 +256,7 @@ export async function writerSettingsDrift(ctx, input) {
|
|
|
233
256
|
}
|
|
234
257
|
}
|
|
235
258
|
export async function provisionWriter(ctx, input) {
|
|
259
|
+
ctx = leasedContext(ctx);
|
|
236
260
|
try {
|
|
237
261
|
const { index, settings } = input;
|
|
238
262
|
const name = writerName(ctx.alias, index), paths = writerPaths(ctx.home, name);
|
|
@@ -271,16 +295,17 @@ export async function provisionWriter(ctx, input) {
|
|
|
271
295
|
// With spool delivery the spool directory exists from provisioning on: launch would otherwise add it to the
|
|
272
296
|
// writer root, a host directory the acceptance harness digests around its own first launch.
|
|
273
297
|
const spool = config.events?.delivery === "spool" ? [paths.events] : [];
|
|
274
|
-
|
|
298
|
+
const cache = settings.cache === "go" ? [paths.go] : settings.cache === "uv" ? [paths.uv] : [];
|
|
299
|
+
for (const path of [paths.root, paths.clone, paths.config, paths.agent, paths.bundles, ...spool, ...cache])
|
|
275
300
|
await privateDirectory(path);
|
|
276
301
|
const state = {
|
|
277
302
|
schemaVersion: 1, alias: ctx.alias, index, phase: "provisioning", lifecycle: settings.lifecycle,
|
|
278
|
-
profiles: [...settings.profiles], providers, network: settings.network, cache: settings.cache, imageId,
|
|
303
|
+
profiles: [...settings.profiles], providers, network: settings.network, cache: settings.cache, tags: settings.tags, imageId,
|
|
279
304
|
base: { ref: `refs/heads/${short}`, commit },
|
|
280
305
|
};
|
|
281
306
|
await writeWriterState(ctx.home, name, state);
|
|
282
|
-
await baseBundle(ctx, paths, ref);
|
|
283
|
-
await cloneFromBundle(ctx, paths, imageId, base, remote);
|
|
307
|
+
await baseBundle(ctx, paths, ref, commit, settings.tags);
|
|
308
|
+
await cloneFromBundle(ctx, paths, imageId, base, remote, settings.tags);
|
|
284
309
|
await jsonFile(join(paths.config, "pi-session-mcp.json"), config);
|
|
285
310
|
await jsonFile(join(paths.config, "execution-modes.json"), { schemaVersion: 1, workspaces: { [ctx.alias]: { mode: "host", acceptHostWrites: true } } });
|
|
286
311
|
await jsonFile(join(paths.config, "writer-guard.json"), { schemaVersion: 1, workspace: ctx.alias, baseRef: state.base.ref, baseCommit: commit });
|
|
@@ -294,13 +319,14 @@ export async function provisionWriter(ctx, input) {
|
|
|
294
319
|
}
|
|
295
320
|
}
|
|
296
321
|
export async function inspectWork(ctx, name) {
|
|
322
|
+
ctx = leasedContext(ctx);
|
|
297
323
|
const state = await readWriterState(ctx.home, name);
|
|
298
324
|
if (!state || state.alias !== ctx.alias)
|
|
299
325
|
throw new LifecycleError("writer_state_invalid");
|
|
300
326
|
const paths = writerPaths(ctx.home, name);
|
|
301
327
|
await verifyCloneLayout(paths.clone);
|
|
302
328
|
const script = `set -eu\nexport GIT_CONFIG_NOSYSTEM=1\ncp .git/index /tmp/index\nmkdir /tmp/objects\nexport GIT_INDEX_FILE=/tmp/index GIT_OBJECT_DIRECTORY=/tmp/objects GIT_ALTERNATE_OBJECT_DIRECTORIES="$1/.git/objects"\n${BOUNDARY_GIT} add -A\n${BOUNDARY_GIT} rev-parse --verify HEAD\n${BOUNDARY_GIT} for-each-ref --format='%(refname) %(objectname)' refs/heads refs/stash\n${BOUNDARY_GIT} write-tree\n${BOUNDARY_GIT} rev-parse 'HEAD^{tree}'\n${BOUNDARY_GIT} rev-parse "$2^{tree}"`;
|
|
303
|
-
const result = await ctx.runner.run("podman", throwawayArgs(paths, state.imageId, { clone: "ro", bundles: false }, script, [paths.clone, state.base.commit]), AbortSignal.timeout(IMAGE_PULL_TIMEOUT_MS));
|
|
329
|
+
const result = await ctx.runner.run("podman", throwawayArgs(name, paths, state.imageId, { clone: "ro", bundles: false }, script, [paths.clone, state.base.commit]), AbortSignal.timeout(IMAGE_PULL_TIMEOUT_MS));
|
|
304
330
|
if (result.exitCode !== 0)
|
|
305
331
|
throw new LifecycleError("writer_inspect_failed");
|
|
306
332
|
const lines = result.stdout.trim().split("\n");
|
|
@@ -332,7 +358,7 @@ async function writerBase(ctx, name, state, requested) {
|
|
|
332
358
|
// State normalizes remote branches to local heads. The original source ref remains
|
|
333
359
|
// in the single-ref base bundle, including when no corresponding host head exists.
|
|
334
360
|
const heads = await git(ctx, ["bundle", "list-heads", join(writerPaths(ctx.home, name).bundles, "base.bundle")], true);
|
|
335
|
-
const match = /^(?:[0-9a-f]{40}|[0-9a-f]{64}) (refs\/(?:heads\/.+|remotes\/[^/]+\/.+))
|
|
361
|
+
const match = /^(?:[0-9a-f]{40}|[0-9a-f]{64}) (refs\/(?:heads\/.+|remotes\/[^/]+\/.+))$/m.exec(heads ?? "");
|
|
336
362
|
const source = match?.[1];
|
|
337
363
|
if (source && isSafeRefName(source) && shortBranch(source) === shortBranch(state.base.ref))
|
|
338
364
|
return resolveBase(ctx, source);
|
|
@@ -353,6 +379,7 @@ async function refreshPrivateFiles(ctx, name, state) {
|
|
|
353
379
|
await jsonFile(join(paths.agent, "auth.json"), {});
|
|
354
380
|
}
|
|
355
381
|
export async function collectWriter(ctx, name) {
|
|
382
|
+
ctx = leasedContext(ctx);
|
|
356
383
|
const refs = [], findings = [];
|
|
357
384
|
try {
|
|
358
385
|
const paths = writerPaths(ctx.home, name);
|
|
@@ -394,7 +421,7 @@ if [ -z "$(${BOUNDARY_GIT} --git-dir=/tmp/scratch for-each-ref --format='%(refna
|
|
|
394
421
|
else
|
|
395
422
|
${BOUNDARY_GIT} --git-dir=/tmp/scratch bundle create /bundles/collect.bundle --all
|
|
396
423
|
fi`;
|
|
397
|
-
const result = await ctx.runner.run("podman", throwawayArgs(paths, state.imageId, { clone: "ro", bundles: true }, script, [paths.clone, state.base.ref, state.base.commit, shortBranch(state.base.ref)]), AbortSignal.timeout(IMAGE_PULL_TIMEOUT_MS));
|
|
424
|
+
const result = await ctx.runner.run("podman", throwawayArgs(name, paths, state.imageId, { clone: "ro", bundles: true }, script, [paths.clone, state.base.ref, state.base.commit, shortBranch(state.base.ref)]), AbortSignal.timeout(IMAGE_PULL_TIMEOUT_MS));
|
|
398
425
|
if (result.exitCode !== 0)
|
|
399
426
|
throw new LifecycleError("writer_collect_failed");
|
|
400
427
|
const messages = result.stdout.split("\n");
|
|
@@ -437,7 +464,7 @@ async function recreate(ctx, name, state, base) {
|
|
|
437
464
|
const remote = await git(ctx, ["remote", "get-url", "origin"], true);
|
|
438
465
|
if (remote && /^[A-Za-z][A-Za-z0-9+.-]*:\/\/[^/]*@/.test(remote))
|
|
439
466
|
throw new LifecycleError("remote_url_refused");
|
|
440
|
-
await baseBundle(ctx, paths, base.ref);
|
|
467
|
+
await baseBundle(ctx, paths, base.ref, base.commit, state.tags);
|
|
441
468
|
const { collected: _collected, ...retained } = state;
|
|
442
469
|
const next = { ...retained, base: { ref: `refs/heads/${base.short}`, commit: base.commit } };
|
|
443
470
|
await writeWriterState(ctx.home, name, { ...next, phase: "provisioning" });
|
|
@@ -445,7 +472,7 @@ async function recreate(ctx, name, state, base) {
|
|
|
445
472
|
await rm(path, { recursive: true, force: true });
|
|
446
473
|
await privateDirectory(path);
|
|
447
474
|
}
|
|
448
|
-
await cloneFromBundle(ctx, paths, state.imageId, base, remote);
|
|
475
|
+
await cloneFromBundle(ctx, paths, state.imageId, base, remote, state.tags);
|
|
449
476
|
await refreshPrivateFiles(ctx, name, next);
|
|
450
477
|
await writeWriterState(ctx.home, name, next);
|
|
451
478
|
if (!(await inspectWork(ctx, name)).baseline)
|
|
@@ -453,26 +480,30 @@ async function recreate(ctx, name, state, base) {
|
|
|
453
480
|
return { status: "ok", code: "writer_recreated" };
|
|
454
481
|
}
|
|
455
482
|
export async function recreateWriter(ctx, name, opts) {
|
|
483
|
+
ctx = leasedContext(ctx);
|
|
456
484
|
try {
|
|
457
485
|
const state = await requiredState(ctx, name);
|
|
458
486
|
await requireCollected(ctx, name, state, opts.discard);
|
|
459
|
-
return await recreate(ctx, name, state, await writerBase(ctx, name, state));
|
|
487
|
+
return await recreate(ctx, name, state, await writerBase(ctx, name, state, opts.baseRef));
|
|
460
488
|
}
|
|
461
489
|
catch (error) {
|
|
462
490
|
return failure(error);
|
|
463
491
|
}
|
|
464
492
|
}
|
|
465
493
|
export async function resetWriter(ctx, name, opts) {
|
|
494
|
+
ctx = leasedContext(ctx);
|
|
466
495
|
try {
|
|
467
496
|
const state = await requiredState(ctx, name);
|
|
468
497
|
const base = await writerBase(ctx, name, state, opts.baseRef);
|
|
469
498
|
await requireCollected(ctx, name, state, opts.discard);
|
|
470
499
|
const paths = writerPaths(ctx.home, name);
|
|
471
500
|
await verifyCloneLayout(paths.clone);
|
|
472
|
-
await baseBundle(ctx, paths, base.ref);
|
|
501
|
+
await baseBundle(ctx, paths, base.ref, base.commit, state.tags);
|
|
473
502
|
const script = `set -eu
|
|
474
503
|
export GIT_CONFIG_NOSYSTEM=1
|
|
475
504
|
${BOUNDARY_GIT} checkout -f --detach
|
|
505
|
+
${BOUNDARY_GIT} for-each-ref --format='%(refname)' refs/tags | while read -r ref; do ${BOUNDARY_GIT} update-ref -d "$ref"; done
|
|
506
|
+
${state.tags === "reachable" ? `${BOUNDARY_GIT} fetch --no-tags /bundles/base.bundle '+refs/tags/*:refs/tags/*'` : ""}
|
|
476
507
|
${BOUNDARY_GIT} fetch --no-tags /bundles/base.bundle "+$1:$1"
|
|
477
508
|
${BOUNDARY_GIT} checkout -f -B "$2" "$3"
|
|
478
509
|
${BOUNDARY_GIT} reset --hard "$3"
|
|
@@ -480,8 +511,9 @@ ${BOUNDARY_GIT} for-each-ref --format='%(refname)' refs/heads | while read -r re
|
|
|
480
511
|
if [ "$ref" != "refs/heads/$2" ]; then ${BOUNDARY_GIT} update-ref -d "$ref"; fi
|
|
481
512
|
done
|
|
482
513
|
${BOUNDARY_GIT} stash clear
|
|
483
|
-
${BOUNDARY_GIT} clean -fd
|
|
484
|
-
|
|
514
|
+
${BOUNDARY_GIT} clean -fd
|
|
515
|
+
${identityScript(4, 5)}`;
|
|
516
|
+
const result = await ctx.runner.run("podman", throwawayArgs(name, paths, state.imageId, { clone: "rw", bundles: true }, script, [base.ref, base.short, base.commit, ...await operatorIdentity(ctx)]), AbortSignal.timeout(IMAGE_PULL_TIMEOUT_MS));
|
|
485
517
|
if (result.exitCode !== 0)
|
|
486
518
|
throw new LifecycleError("writer_reset_failed");
|
|
487
519
|
const next = { ...state, base: { ref: `refs/heads/${base.short}`, commit: base.commit } };
|
|
@@ -503,13 +535,13 @@ ${BOUNDARY_GIT} clean -fd`;
|
|
|
503
535
|
return failure(error);
|
|
504
536
|
}
|
|
505
537
|
}
|
|
506
|
-
export async function finishWriter(ctx, name) {
|
|
538
|
+
export async function finishWriter(ctx, name, opts = {}) {
|
|
507
539
|
try {
|
|
508
540
|
const state = await requiredState(ctx, name);
|
|
509
541
|
const collected = await collectWriter(ctx, name);
|
|
510
542
|
if (collected.status === "failed")
|
|
511
543
|
return collected;
|
|
512
|
-
return state.lifecycle === "ephemeral" ? await recreateWriter(ctx, name, { discard: false }) : await resetWriter(ctx, name, { discard: false });
|
|
544
|
+
return state.lifecycle === "ephemeral" ? await recreateWriter(ctx, name, { discard: false, ...opts }) : await resetWriter(ctx, name, { discard: false, ...opts });
|
|
513
545
|
}
|
|
514
546
|
catch (error) {
|
|
515
547
|
return failure(error);
|