@runuai/host 0.9.14 → 0.9.42
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/README.md +22 -5
- package/db/migrations/0014_host_inventory_event_index.sql +1 -0
- package/db/migrations/0015_host_settings.sql +9 -0
- package/db/migrations/0016_task_environment.sql +2 -0
- package/db/migrations/meta/_journal.json +21 -0
- package/db/schema.ts +80 -30
- package/images/standard/Dockerfile +36 -10
- package/images/standard/README.md +63 -18
- package/images/standard/container/corepack-version +1 -0
- package/images/standard/container/uai-init +308 -38
- package/images/standard/container/uai-materialize-runtimes +1527 -0
- package/lib/agent-cli.ts +33 -2
- package/lib/agent.ts +46 -7
- package/lib/agents/claude.ts +13 -8
- package/lib/agents/codex.ts +11 -6
- package/lib/agents/cursor.ts +39 -29
- package/lib/agents/durable-proc.ts +20 -27
- package/lib/agents/factory.ts +9 -25
- package/lib/agents/grok.ts +43 -30
- package/lib/agents/kimi.ts +44 -29
- package/lib/agents/opencode.ts +43 -31
- package/lib/agents/proc.ts +149 -114
- package/lib/agents/transport.ts +62 -50
- package/lib/agents/types.ts +6 -4
- package/lib/apple-runtime-recycle.ts +236 -0
- package/lib/apple-uninstall-teardown.ts +224 -0
- package/lib/browser-testing.ts +233 -93
- package/lib/codex-auth.ts +40 -6
- package/lib/command-db.ts +20 -0
- package/lib/container-runtime.ts +1338 -0
- package/lib/db.ts +1 -0
- package/lib/docker-exec.ts +87 -5
- package/lib/engine-accounts.ts +68 -5
- package/lib/engine-login.ts +1952 -0
- package/lib/enrollment-state.ts +251 -0
- package/lib/env-file.ts +155 -0
- package/lib/env.ts +4 -0
- package/lib/git-diff.ts +98 -32
- package/lib/git-identity.ts +199 -87
- package/lib/github-tokens.ts +202 -91
- package/lib/host-cloud-url.ts +62 -0
- package/lib/host-config.ts +279 -0
- package/lib/host-logs.ts +962 -0
- package/lib/keyed-promise-tail.ts +23 -0
- package/lib/legacy-runtime-v1.fixture.ts +627 -0
- package/lib/managed-activation-watcher.ts +72 -0
- package/lib/managed-install-owner-watcher.ts +55 -0
- package/lib/managed-operation-drain.ts +49 -0
- package/lib/managed-runtime.ts +3644 -0
- package/lib/managed-update-scheduler.ts +125 -0
- package/lib/mcp-gateway.ts +450 -23
- package/lib/orchestrator.ts +3060 -218
- package/lib/preview-sidecar.ts +57 -13
- package/lib/release-manifest.ts +708 -0
- package/lib/release-trust.ts +28 -0
- package/lib/runtime-activation-tail.ts +232 -0
- package/lib/runtime-archive.ts +1086 -0
- package/lib/runtime-authority.ts +79 -0
- package/lib/runtime-guard.ts +36 -0
- package/lib/runtime-provider-state.ts +169 -0
- package/lib/runtime-state.ts +232 -12
- package/lib/skills.ts +24 -3
- package/lib/ssh.ts +18 -0
- package/lib/standard-image.ts +1104 -141
- package/lib/stopped-task-status-queue.ts +44 -0
- package/lib/task-container-cli.ts +269 -0
- package/lib/task-diff.ts +66 -46
- package/lib/task-environment/apple-container.ts +757 -0
- package/lib/task-environment/docker.ts +945 -0
- package/lib/task-environment/index.ts +364 -0
- package/lib/task-environment/legacy-adoption.ts +443 -0
- package/lib/task-environment/registry.ts +58 -0
- package/lib/task-environment/types.ts +408 -0
- package/lib/task-identity.ts +19 -0
- package/lib/task-inventory.ts +585 -0
- package/lib/tunnel-registry.ts +135 -19
- package/lib/tunnel-runtime.ts +235 -0
- package/package.json +1 -1
- package/scripts/agent/_common.sh +123 -3
- package/scripts/agent/task-down.sh +146 -38
- package/scripts/agent/task-status.sh +19 -3
- package/scripts/agent/task-up.sh +1405 -107
- package/scripts/install/darwin.ts +848 -50
- package/scripts/install/linux.ts +838 -35
- package/scripts/install/types.ts +43 -0
- package/scripts/install/util.ts +215 -8
- package/scripts/install/win.ts +12 -0
- package/src/apple-tunnel-route.ts +104 -0
- package/src/cli.ts +1464 -72
- package/src/event-outbox.ts +83 -4
- package/src/index.ts +766 -42
- package/src/main.ts +1398 -255
- package/src/paths.ts +17 -1
- package/src/protocol.ts +695 -1
- package/src/runtime-bootstrap.ts +165 -0
- package/src/ui/server.ts +46 -10
- package/src/ui/types.ts +37 -0
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Crash-safe local state for first-time host enrollment (ADR-099).
|
|
3
|
+
*
|
|
4
|
+
* A host chooses its permanent id/secret before redeeming a one-time cloud
|
|
5
|
+
* token. Persist that proposal before the request so a lost response or a
|
|
6
|
+
* process crash can replay the exact same identity. The raw enrollment token
|
|
7
|
+
* is never written to disk; only its sha256 fingerprint is retained.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
11
|
+
import {
|
|
12
|
+
chmodSync,
|
|
13
|
+
closeSync,
|
|
14
|
+
existsSync,
|
|
15
|
+
fsyncSync,
|
|
16
|
+
linkSync,
|
|
17
|
+
lstatSync,
|
|
18
|
+
mkdirSync,
|
|
19
|
+
openSync,
|
|
20
|
+
readFileSync,
|
|
21
|
+
unlinkSync,
|
|
22
|
+
writeFileSync,
|
|
23
|
+
} from "node:fs";
|
|
24
|
+
import { basename, dirname, join } from "node:path";
|
|
25
|
+
|
|
26
|
+
import { writeEnvValuesAtomic } from "./env-file";
|
|
27
|
+
import { parseHostCloudEndpoint } from "./host-cloud-url";
|
|
28
|
+
|
|
29
|
+
const HOST_ID = /^[a-z0-9_-]{1,64}$/;
|
|
30
|
+
const SHA256 = /^[a-f0-9]{64}$/;
|
|
31
|
+
|
|
32
|
+
export { writeEnvValuesAtomic } from "./env-file";
|
|
33
|
+
|
|
34
|
+
export interface PendingEnrollment {
|
|
35
|
+
readonly schemaVersion: 1;
|
|
36
|
+
readonly enrollmentTokenSha256: string;
|
|
37
|
+
readonly hostId: string;
|
|
38
|
+
readonly hostSecret: string;
|
|
39
|
+
readonly cloudUrl: string;
|
|
40
|
+
readonly hostName: string;
|
|
41
|
+
readonly createdAt: number;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function pendingEnrollmentPath(uaiHome: string): string {
|
|
45
|
+
return join(uaiHome, ".enrollment-pending.json");
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function enrollmentTokenSha256(token: string): string {
|
|
49
|
+
return createHash("sha256").update(token).digest("hex");
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function pendingEnrollmentMatches(
|
|
53
|
+
pending: PendingEnrollment,
|
|
54
|
+
enrollmentToken: string,
|
|
55
|
+
cloudUrl: string,
|
|
56
|
+
): boolean {
|
|
57
|
+
return (
|
|
58
|
+
pending.enrollmentTokenSha256 === enrollmentTokenSha256(enrollmentToken) &&
|
|
59
|
+
pending.cloudUrl === cloudUrl
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function readPendingEnrollment(file: string): PendingEnrollment | null {
|
|
64
|
+
if (!existsSync(file)) return null;
|
|
65
|
+
const stat = lstatSync(file);
|
|
66
|
+
if (!stat.isFile() || stat.isSymbolicLink()) {
|
|
67
|
+
throw new Error("pending enrollment state must be a regular file");
|
|
68
|
+
}
|
|
69
|
+
let parsed: unknown;
|
|
70
|
+
try {
|
|
71
|
+
parsed = JSON.parse(readFileSync(file, "utf8"));
|
|
72
|
+
} catch {
|
|
73
|
+
throw new Error("pending enrollment state is malformed");
|
|
74
|
+
}
|
|
75
|
+
return validatePendingEnrollment(parsed);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Create the immutable pending proposal without an overwrite race. A complete
|
|
80
|
+
* 0600 temp file is fsync'd and hard-linked into place, so the durable path is
|
|
81
|
+
* never observable with partial JSON. If another process won, return its
|
|
82
|
+
* strictly validated proposal instead.
|
|
83
|
+
*/
|
|
84
|
+
export function ensurePendingEnrollment(
|
|
85
|
+
file: string,
|
|
86
|
+
candidate: PendingEnrollment,
|
|
87
|
+
): { pending: PendingEnrollment; created: boolean } {
|
|
88
|
+
const normalized = validatePendingEnrollment(candidate);
|
|
89
|
+
const parent = dirname(file);
|
|
90
|
+
mkdirSync(parent, { recursive: true, mode: 0o700 });
|
|
91
|
+
|
|
92
|
+
const existing = readPendingEnrollment(file);
|
|
93
|
+
if (existing) return { pending: existing, created: false };
|
|
94
|
+
|
|
95
|
+
const temporary = join(
|
|
96
|
+
parent,
|
|
97
|
+
`.${basename(file)}.${process.pid}.${randomBytes(8).toString("hex")}.tmp`,
|
|
98
|
+
);
|
|
99
|
+
try {
|
|
100
|
+
writeDurableFile(temporary, `${JSON.stringify(normalized, null, 2)}\n`, true);
|
|
101
|
+
try {
|
|
102
|
+
linkSync(temporary, file);
|
|
103
|
+
fsyncDirectory(parent);
|
|
104
|
+
return { pending: normalized, created: true };
|
|
105
|
+
} catch (error) {
|
|
106
|
+
if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
|
|
107
|
+
const winner = readPendingEnrollment(file);
|
|
108
|
+
if (!winner) throw new Error("pending enrollment winner disappeared");
|
|
109
|
+
return { pending: winner, created: false };
|
|
110
|
+
}
|
|
111
|
+
} finally {
|
|
112
|
+
try {
|
|
113
|
+
unlinkSync(temporary);
|
|
114
|
+
} catch (error) {
|
|
115
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Commit the exact pending identity to dotenv, then remove the journal. The
|
|
122
|
+
* ordering is intentional: a crash can leave both files, but can never leave
|
|
123
|
+
* neither. A later invocation can recognize and remove the harmless journal.
|
|
124
|
+
*/
|
|
125
|
+
export function commitPendingEnrollment(
|
|
126
|
+
pendingFile: string,
|
|
127
|
+
envFile: string,
|
|
128
|
+
expected: PendingEnrollment,
|
|
129
|
+
): void {
|
|
130
|
+
const current = readPendingEnrollment(pendingFile);
|
|
131
|
+
if (!current || JSON.stringify(current) !== JSON.stringify(expected)) {
|
|
132
|
+
throw new Error("pending enrollment state changed before commit");
|
|
133
|
+
}
|
|
134
|
+
writeEnvValuesAtomic(envFile, {
|
|
135
|
+
UAI_HOST_ID: expected.hostId,
|
|
136
|
+
UAI_HOST_TOKEN: expected.hostSecret,
|
|
137
|
+
UAI_CLOUD_URL: expected.cloudUrl,
|
|
138
|
+
});
|
|
139
|
+
removePendingEnrollment(pendingFile);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export function removeCommittedPendingEnrollment(
|
|
143
|
+
file: string,
|
|
144
|
+
identity: { hostId: string; hostSecret: string },
|
|
145
|
+
): boolean {
|
|
146
|
+
const pending = readPendingEnrollment(file);
|
|
147
|
+
if (!pending) return false;
|
|
148
|
+
if (
|
|
149
|
+
pending.hostId !== identity.hostId ||
|
|
150
|
+
pending.hostSecret !== identity.hostSecret
|
|
151
|
+
) {
|
|
152
|
+
return false;
|
|
153
|
+
}
|
|
154
|
+
removePendingEnrollment(file);
|
|
155
|
+
return true;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Deliberate operator reset used only by the existing `setup --force` path. */
|
|
159
|
+
export function discardPendingEnrollment(file: string): void {
|
|
160
|
+
removePendingEnrollment(file);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function removePendingEnrollment(file: string): void {
|
|
164
|
+
try {
|
|
165
|
+
unlinkSync(file);
|
|
166
|
+
fsyncDirectory(dirname(file));
|
|
167
|
+
} catch (error) {
|
|
168
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function writeDurableFile(file: string, body: string, exclusive: boolean): void {
|
|
173
|
+
const descriptor = openSync(file, exclusive ? "wx" : "w", 0o600);
|
|
174
|
+
try {
|
|
175
|
+
writeFileSync(descriptor, body, "utf8");
|
|
176
|
+
fsyncSync(descriptor);
|
|
177
|
+
} finally {
|
|
178
|
+
closeSync(descriptor);
|
|
179
|
+
}
|
|
180
|
+
chmodSync(file, 0o600);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function fsyncDirectory(directory: string): void {
|
|
184
|
+
let descriptor: number | undefined;
|
|
185
|
+
try {
|
|
186
|
+
descriptor = openSync(directory, "r");
|
|
187
|
+
fsyncSync(descriptor);
|
|
188
|
+
} catch (error) {
|
|
189
|
+
// Some platforms/filesystems do not permit fsync on a directory. The file
|
|
190
|
+
// itself was still fsync'd before rename/link; do not make enrollment
|
|
191
|
+
// unusable solely because the directory durability primitive is absent.
|
|
192
|
+
const code = (error as NodeJS.ErrnoException).code;
|
|
193
|
+
if (code !== "EINVAL" && code !== "ENOTSUP" && code !== "EISDIR") throw error;
|
|
194
|
+
} finally {
|
|
195
|
+
if (descriptor !== undefined) closeSync(descriptor);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function validatePendingEnrollment(value: unknown): PendingEnrollment {
|
|
200
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
201
|
+
throw new Error("pending enrollment state must be an object");
|
|
202
|
+
}
|
|
203
|
+
const object = value as Record<string, unknown>;
|
|
204
|
+
const expected = [
|
|
205
|
+
"schemaVersion",
|
|
206
|
+
"enrollmentTokenSha256",
|
|
207
|
+
"hostId",
|
|
208
|
+
"hostSecret",
|
|
209
|
+
"cloudUrl",
|
|
210
|
+
"hostName",
|
|
211
|
+
"createdAt",
|
|
212
|
+
];
|
|
213
|
+
if (
|
|
214
|
+
Object.keys(object).sort().join("\0") !== expected.sort().join("\0") ||
|
|
215
|
+
object.schemaVersion !== 1 ||
|
|
216
|
+
typeof object.enrollmentTokenSha256 !== "string" ||
|
|
217
|
+
!SHA256.test(object.enrollmentTokenSha256) ||
|
|
218
|
+
typeof object.hostId !== "string" ||
|
|
219
|
+
!HOST_ID.test(object.hostId) ||
|
|
220
|
+
typeof object.hostSecret !== "string" ||
|
|
221
|
+
!SHA256.test(object.hostSecret) ||
|
|
222
|
+
typeof object.cloudUrl !== "string" ||
|
|
223
|
+
object.cloudUrl.length < 1 ||
|
|
224
|
+
object.cloudUrl.length > 2_048 ||
|
|
225
|
+
typeof object.hostName !== "string" ||
|
|
226
|
+
object.hostName.length > 128 ||
|
|
227
|
+
typeof object.createdAt !== "number" ||
|
|
228
|
+
!Number.isSafeInteger(object.createdAt) ||
|
|
229
|
+
object.createdAt <= 0
|
|
230
|
+
) {
|
|
231
|
+
throw new Error("pending enrollment state is invalid");
|
|
232
|
+
}
|
|
233
|
+
let canonicalCloudUrl: string;
|
|
234
|
+
try {
|
|
235
|
+
canonicalCloudUrl = parseHostCloudEndpoint(object.cloudUrl).bridgeUrl;
|
|
236
|
+
} catch {
|
|
237
|
+
throw new Error("pending enrollment state is invalid");
|
|
238
|
+
}
|
|
239
|
+
if (canonicalCloudUrl !== object.cloudUrl) {
|
|
240
|
+
throw new Error("pending enrollment state is invalid");
|
|
241
|
+
}
|
|
242
|
+
return {
|
|
243
|
+
schemaVersion: 1,
|
|
244
|
+
enrollmentTokenSha256: object.enrollmentTokenSha256,
|
|
245
|
+
hostId: object.hostId,
|
|
246
|
+
hostSecret: object.hostSecret,
|
|
247
|
+
cloudUrl: object.cloudUrl,
|
|
248
|
+
hostName: object.hostName,
|
|
249
|
+
createdAt: object.createdAt,
|
|
250
|
+
};
|
|
251
|
+
}
|
package/lib/env-file.ts
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Durable `.env.local` updates shared by every host-side settings writer.
|
|
3
|
+
*
|
|
4
|
+
* A mutation is a read/merge/fsync/rename operation, so concurrent writers
|
|
5
|
+
* must serialize the entire transition rather than only the final rename.
|
|
6
|
+
* `serializeEnvFileMutation` is the process-wide boundary used by remote host
|
|
7
|
+
* config today and interactive engine-login persistence later (ADR-100).
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { randomBytes } from "node:crypto";
|
|
11
|
+
import {
|
|
12
|
+
chmodSync,
|
|
13
|
+
closeSync,
|
|
14
|
+
existsSync,
|
|
15
|
+
fsyncSync,
|
|
16
|
+
mkdirSync,
|
|
17
|
+
openSync,
|
|
18
|
+
readFileSync,
|
|
19
|
+
renameSync,
|
|
20
|
+
unlinkSync,
|
|
21
|
+
writeFileSync,
|
|
22
|
+
} from "node:fs";
|
|
23
|
+
import { basename, dirname, resolve } from "node:path";
|
|
24
|
+
|
|
25
|
+
const ENV_KEY = /^[A-Z][A-Z0-9_]*$/;
|
|
26
|
+
const mutationTails = new Map<string, Promise<void>>();
|
|
27
|
+
|
|
28
|
+
export interface EnvFileSnapshot {
|
|
29
|
+
readonly exists: boolean;
|
|
30
|
+
readonly body: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Queue one complete mutation for an env file. A failed predecessor never
|
|
35
|
+
* poisons the queue, and the tail is removed once the last waiter settles.
|
|
36
|
+
*/
|
|
37
|
+
export function serializeEnvFileMutation<T>(
|
|
38
|
+
file: string,
|
|
39
|
+
operation: () => T | Promise<T>,
|
|
40
|
+
): Promise<T> {
|
|
41
|
+
const key = resolve(file);
|
|
42
|
+
const predecessor = mutationTails.get(key) ?? Promise.resolve();
|
|
43
|
+
const running = predecessor.catch(() => undefined).then(operation);
|
|
44
|
+
const settled = running.then(
|
|
45
|
+
() => undefined,
|
|
46
|
+
() => undefined,
|
|
47
|
+
);
|
|
48
|
+
mutationTails.set(key, settled);
|
|
49
|
+
void settled.finally(() => {
|
|
50
|
+
if (mutationTails.get(key) === settled) mutationTails.delete(key);
|
|
51
|
+
});
|
|
52
|
+
return running;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Atomically merge all supplied values into one durable 0600 dotenv file. */
|
|
56
|
+
export function writeEnvValuesAtomic(
|
|
57
|
+
file: string,
|
|
58
|
+
updates: Readonly<Record<string, string>>,
|
|
59
|
+
): void {
|
|
60
|
+
const entries = Object.entries(updates);
|
|
61
|
+
if (entries.length === 0) return;
|
|
62
|
+
for (const [key, value] of entries) {
|
|
63
|
+
if (!ENV_KEY.test(key)) throw new Error(`invalid environment key: ${key}`);
|
|
64
|
+
if (value.includes("\0") || value.includes("\n") || value.includes("\r")) {
|
|
65
|
+
throw new Error(`environment value for ${key} contains a line break`);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const keys = new Set(entries.map(([key]) => key));
|
|
70
|
+
const current = existsSync(file) ? readFileSync(file, "utf8") : "";
|
|
71
|
+
const kept = current
|
|
72
|
+
.split(/\r?\n/)
|
|
73
|
+
.filter((line, index, lines) => {
|
|
74
|
+
if (index === lines.length - 1 && line === "") return false;
|
|
75
|
+
const equals = line.indexOf("=");
|
|
76
|
+
return equals <= 0 || !keys.has(line.slice(0, equals));
|
|
77
|
+
});
|
|
78
|
+
for (const [key, value] of entries) kept.push(`${key}=${value}`);
|
|
79
|
+
replaceEnvFileAtomic(file, `${kept.join("\n")}\n`);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Capture the exact bytes needed to roll back a multi-store config write. */
|
|
83
|
+
export function readEnvFileSnapshot(file: string): EnvFileSnapshot {
|
|
84
|
+
return existsSync(file)
|
|
85
|
+
? { exists: true, body: readFileSync(file, "utf8") }
|
|
86
|
+
: { exists: false, body: "" };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Restore a prior snapshot durably. Successful replacements remain 0600. */
|
|
90
|
+
export function restoreEnvFileSnapshotAtomic(
|
|
91
|
+
file: string,
|
|
92
|
+
snapshot: EnvFileSnapshot,
|
|
93
|
+
): void {
|
|
94
|
+
if (snapshot.exists) {
|
|
95
|
+
replaceEnvFileAtomic(file, snapshot.body);
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
try {
|
|
99
|
+
unlinkSync(file);
|
|
100
|
+
fsyncDirectory(dirname(file));
|
|
101
|
+
} catch (error) {
|
|
102
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function replaceEnvFileAtomic(file: string, body: string): void {
|
|
107
|
+
const parent = dirname(file);
|
|
108
|
+
mkdirSync(parent, { recursive: true, mode: 0o700 });
|
|
109
|
+
const temporary = joinTemporary(parent, basename(file));
|
|
110
|
+
try {
|
|
111
|
+
writeDurableFile(temporary, body);
|
|
112
|
+
renameSync(temporary, file);
|
|
113
|
+
chmodSync(file, 0o600);
|
|
114
|
+
fsyncDirectory(parent);
|
|
115
|
+
} finally {
|
|
116
|
+
try {
|
|
117
|
+
unlinkSync(temporary);
|
|
118
|
+
} catch (error) {
|
|
119
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function joinTemporary(parent: string, name: string): string {
|
|
125
|
+
return resolve(
|
|
126
|
+
parent,
|
|
127
|
+
`.${name}.${process.pid}.${randomBytes(8).toString("hex")}.tmp`,
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function writeDurableFile(file: string, body: string): void {
|
|
132
|
+
const descriptor = openSync(file, "wx", 0o600);
|
|
133
|
+
try {
|
|
134
|
+
writeFileSync(descriptor, body, "utf8");
|
|
135
|
+
fsyncSync(descriptor);
|
|
136
|
+
} finally {
|
|
137
|
+
closeSync(descriptor);
|
|
138
|
+
}
|
|
139
|
+
chmodSync(file, 0o600);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function fsyncDirectory(directory: string): void {
|
|
143
|
+
let descriptor: number | undefined;
|
|
144
|
+
try {
|
|
145
|
+
descriptor = openSync(directory, "r");
|
|
146
|
+
fsyncSync(descriptor);
|
|
147
|
+
} catch (error) {
|
|
148
|
+
const code = (error as NodeJS.ErrnoException).code;
|
|
149
|
+
if (code !== "EINVAL" && code !== "ENOTSUP" && code !== "EISDIR") {
|
|
150
|
+
throw error;
|
|
151
|
+
}
|
|
152
|
+
} finally {
|
|
153
|
+
if (descriptor !== undefined) closeSync(descriptor);
|
|
154
|
+
}
|
|
155
|
+
}
|
package/lib/env.ts
CHANGED
|
@@ -20,6 +20,10 @@ const schema = z.object({
|
|
|
20
20
|
UAI_HOST_BRIDGE_PORT: z.coerce.number().int().min(1).max(65535).default(8789),
|
|
21
21
|
UAI_CLOUD_URL: z.string().min(1).optional(),
|
|
22
22
|
UAI_HOST_ID: z.string().min(1).optional(),
|
|
23
|
+
// ADR-101: container-runtime.ts validates this separately. Keeping the raw
|
|
24
|
+
// value here prevents one misspelled runtime preference from making the
|
|
25
|
+
// schema fallback discard every otherwise-valid host setting.
|
|
26
|
+
UAI_RUNTIME: z.string().optional(),
|
|
23
27
|
// Host master key file (ADR-027) — 32 bytes, AES-256-GCM for the host
|
|
24
28
|
// secret store. Defaults to $UAI_DATA_DIR/host-master.key; generated on
|
|
25
29
|
// first use.
|
package/lib/git-diff.ts
CHANGED
|
@@ -13,6 +13,12 @@
|
|
|
13
13
|
*/
|
|
14
14
|
import { spawn } from "node:child_process";
|
|
15
15
|
|
|
16
|
+
import { RUNTIME_AUTHORITY_ENV } from "./runtime-authority";
|
|
17
|
+
import type {
|
|
18
|
+
TaskEnvironmentExecResult,
|
|
19
|
+
TaskEnvironmentHandle,
|
|
20
|
+
} from "./task-environment/types";
|
|
21
|
+
|
|
16
22
|
export type DiffStatus =
|
|
17
23
|
| "added"
|
|
18
24
|
| "deleted"
|
|
@@ -71,6 +77,12 @@ const NON_INTERACTIVE_GIT_ENV = {
|
|
|
71
77
|
GIT_SSH_COMMAND: "ssh -o BatchMode=yes",
|
|
72
78
|
};
|
|
73
79
|
|
|
80
|
+
const TASK_ENVIRONMENT_GIT_TIMEOUT_MS = 120_000;
|
|
81
|
+
const TASK_ENVIRONMENT_GIT_OUTPUT_BYTES = 64 * 1024 * 1024;
|
|
82
|
+
const TASK_ENVIRONMENT_GIT_TEXT_OUTPUT_BYTES = 4 * 1024 * 1024;
|
|
83
|
+
|
|
84
|
+
type TaskEnvironmentExecutor = Pick<TaskEnvironmentHandle, "exec">;
|
|
85
|
+
|
|
74
86
|
/**
|
|
75
87
|
* Run `git diff <range>` in `cwd` and return the raw unified-diff text.
|
|
76
88
|
* Throws if git exits non-zero. Empty diff returns an empty string.
|
|
@@ -88,23 +100,24 @@ export function runGitDiff(cwd: string, range: string): Promise<string> {
|
|
|
88
100
|
}
|
|
89
101
|
|
|
90
102
|
/**
|
|
91
|
-
* Run `git diff <range>` inside a running task
|
|
103
|
+
* Run `git diff <range>` inside a running task environment.
|
|
92
104
|
*
|
|
93
|
-
*
|
|
94
|
-
* `/workspace`. Running diff there keeps all Git credential behavior
|
|
105
|
+
* Running diff there keeps all Git credential behavior
|
|
95
106
|
* inside the task sandbox instead of leaking to the host's SSH agent
|
|
96
107
|
* (for example macOS 1Password confirmation prompts).
|
|
97
108
|
*/
|
|
98
|
-
export function
|
|
99
|
-
|
|
109
|
+
export function runGitDiffInEnvironment(
|
|
110
|
+
environment: TaskEnvironmentExecutor,
|
|
100
111
|
cwd: string,
|
|
101
112
|
range: string,
|
|
102
113
|
): Promise<string> {
|
|
103
|
-
return
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
114
|
+
return runEnvironmentGitCommand(
|
|
115
|
+
environment,
|
|
116
|
+
cwd,
|
|
117
|
+
[...GIT_DIFF_ARGS, range],
|
|
118
|
+
"task environment git diff",
|
|
119
|
+
false,
|
|
120
|
+
TASK_ENVIRONMENT_GIT_OUTPUT_BYTES,
|
|
108
121
|
);
|
|
109
122
|
}
|
|
110
123
|
|
|
@@ -130,43 +143,96 @@ export function runGitDiffUntracked(
|
|
|
130
143
|
);
|
|
131
144
|
}
|
|
132
145
|
|
|
133
|
-
/** In-
|
|
134
|
-
export function
|
|
135
|
-
|
|
146
|
+
/** In-environment variant of {@link runGitDiffUntracked}. */
|
|
147
|
+
export function runGitDiffUntrackedInEnvironment(
|
|
148
|
+
environment: TaskEnvironmentExecutor,
|
|
136
149
|
cwd: string,
|
|
137
150
|
relPath: string,
|
|
138
151
|
): Promise<string> {
|
|
139
|
-
return
|
|
140
|
-
|
|
152
|
+
return runEnvironmentGitCommand(
|
|
153
|
+
environment,
|
|
154
|
+
cwd,
|
|
141
155
|
[
|
|
142
|
-
...dockerExecPrefix(containerName, cwd),
|
|
143
|
-
"git",
|
|
144
156
|
...GIT_DIFF_ARGS,
|
|
145
157
|
"--no-index",
|
|
146
158
|
"--",
|
|
147
159
|
"/dev/null",
|
|
148
160
|
relPath,
|
|
149
161
|
],
|
|
150
|
-
"
|
|
151
|
-
process.env,
|
|
162
|
+
"task environment git diff --no-index",
|
|
152
163
|
true,
|
|
164
|
+
TASK_ENVIRONMENT_GIT_OUTPUT_BYTES,
|
|
153
165
|
);
|
|
154
166
|
}
|
|
155
167
|
|
|
156
|
-
/**
|
|
157
|
-
function
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
168
|
+
/** Best-effort read-only Git query used for base/untracked discovery. */
|
|
169
|
+
export async function tryRunGitTextInEnvironment(
|
|
170
|
+
environment: TaskEnvironmentExecutor,
|
|
171
|
+
cwd: string,
|
|
172
|
+
args: string[],
|
|
173
|
+
): Promise<string | null> {
|
|
174
|
+
try {
|
|
175
|
+
const text = await runEnvironmentGitCommand(
|
|
176
|
+
environment,
|
|
177
|
+
cwd,
|
|
178
|
+
args,
|
|
179
|
+
"task environment git query",
|
|
180
|
+
false,
|
|
181
|
+
TASK_ENVIRONMENT_GIT_TEXT_OUTPUT_BYTES,
|
|
182
|
+
);
|
|
183
|
+
const trimmed = text.trim();
|
|
184
|
+
return trimmed.length > 0 ? trimmed : null;
|
|
185
|
+
} catch {
|
|
186
|
+
return null;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
async function runEnvironmentGitCommand(
|
|
191
|
+
environment: TaskEnvironmentExecutor,
|
|
192
|
+
cwd: string,
|
|
193
|
+
args: string[],
|
|
194
|
+
label: string,
|
|
195
|
+
allowExitOne: boolean,
|
|
196
|
+
maxOutputBytes: number,
|
|
197
|
+
): Promise<string> {
|
|
198
|
+
const result = await environment.exec({
|
|
199
|
+
argv: ["/usr/bin/git", ...args],
|
|
167
200
|
cwd,
|
|
168
|
-
|
|
169
|
-
|
|
201
|
+
env: { ...RUNTIME_AUTHORITY_ENV, ...NON_INTERACTIVE_GIT_ENV },
|
|
202
|
+
timeoutMs: TASK_ENVIRONMENT_GIT_TIMEOUT_MS,
|
|
203
|
+
maxOutputBytes,
|
|
204
|
+
});
|
|
205
|
+
return requireEnvironmentGitSuccess(
|
|
206
|
+
result,
|
|
207
|
+
label,
|
|
208
|
+
allowExitOne,
|
|
209
|
+
maxOutputBytes,
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function requireEnvironmentGitSuccess(
|
|
214
|
+
result: TaskEnvironmentExecResult,
|
|
215
|
+
label: string,
|
|
216
|
+
allowExitOne: boolean,
|
|
217
|
+
maxOutputBytes: number,
|
|
218
|
+
): string {
|
|
219
|
+
if (result.stdoutTruncated || result.stderrTruncated) {
|
|
220
|
+
throw new Error(`${label} exceeded ${maxOutputBytes} output bytes`);
|
|
221
|
+
}
|
|
222
|
+
if (result.exitCode === 0 || (allowExitOne && result.exitCode === 1)) {
|
|
223
|
+
return Buffer.from(result.stdout).toString("utf8");
|
|
224
|
+
}
|
|
225
|
+
const stderr = Buffer.from(result.stderr)
|
|
226
|
+
.toString("utf8")
|
|
227
|
+
.trim()
|
|
228
|
+
.slice(0, 4_096);
|
|
229
|
+
const outcome =
|
|
230
|
+
result.exitCode === null
|
|
231
|
+
? result.signal
|
|
232
|
+
? `was terminated by ${result.signal}`
|
|
233
|
+
: "did not start"
|
|
234
|
+
: `exited ${result.exitCode}`;
|
|
235
|
+
throw new Error(`${label} ${outcome}${stderr ? `: ${stderr}` : ""}`);
|
|
170
236
|
}
|
|
171
237
|
|
|
172
238
|
function runDiffCommand(
|