@runuai/host 0.9.69 → 0.9.71
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/lib/agents/transport.ts +9 -0
- package/lib/env-schema.ts +101 -0
- package/lib/machine-exec.ts +89 -0
- package/lib/machine-keys.ts +65 -0
- package/lib/machine-provider-aws.ts +437 -0
- package/lib/machine-provider-local.ts +58 -7
- package/lib/machine-provider.ts +10 -0
- package/lib/task-environment/apple-container.ts +21 -0
- package/lib/task-environment/docker.ts +21 -0
- package/lib/task-environment/index.ts +56 -2
- package/lib/task-environment/machine-task-up.ts +333 -0
- package/lib/task-environment/machine.ts +759 -0
- package/lib/task-environment/types.ts +32 -0
- package/lib/task-environment/workspace-files.ts +51 -0
- package/lib/transcript.ts +83 -5
- package/package.json +1 -1
- package/src/index.ts +194 -13
|
@@ -186,6 +186,16 @@ export type TaskEnvironmentRecoveryResult =
|
|
|
186
186
|
export interface TaskEnvironmentHandle<TTeardownResult = void>
|
|
187
187
|
extends TaskEnvironmentAgentSessionSurface {
|
|
188
188
|
readonly descriptor: TaskEnvironmentDescriptor;
|
|
189
|
+
/** ADR-121 strangler ops: task-scoped file IO through the provider, so
|
|
190
|
+
* call sites stop knowing where the workspace physically lives. Paths are
|
|
191
|
+
* ENVIRONMENT-absolute and must sit inside the descriptor's workspace
|
|
192
|
+
* (assertWorkspaceScopedPath). Providers whose workspace is host-side
|
|
193
|
+
* (docker, apple) implement these as direct filesystem access — zero
|
|
194
|
+
* behavior change; machine providers reach over their transport. Read
|
|
195
|
+
* resolves null for a missing file and rejects for any other failure —
|
|
196
|
+
* absence and error are different facts. */
|
|
197
|
+
readWorkspaceFile(path: string): Promise<Uint8Array | null>;
|
|
198
|
+
writeWorkspaceFile(path: string, bytes: Uint8Array): Promise<void>;
|
|
189
199
|
exec(request: TaskEnvironmentExecRequest): Promise<TaskEnvironmentExecResult>;
|
|
190
200
|
spawn(request: TaskEnvironmentSpawnRequest): Promise<TaskEnvironmentProcess>;
|
|
191
201
|
copy(request: TaskEnvironmentCopyRequest): Promise<void>;
|
|
@@ -245,6 +255,28 @@ export interface TaskEnvironmentProvider<
|
|
|
245
255
|
): Promise<TaskEnvironmentHandle<TTeardownResult>>;
|
|
246
256
|
}
|
|
247
257
|
|
|
258
|
+
/** Workspace-scope guard for the strangler file ops: the path must be an
|
|
259
|
+
* absolute, NUL-free, dot-segment-free environment path inside the
|
|
260
|
+
* descriptor's workspace. Escaping the workspace through the file ops would
|
|
261
|
+
* hand callers the environment's whole filesystem. */
|
|
262
|
+
export function assertWorkspaceScopedPath(
|
|
263
|
+
descriptor: TaskEnvironmentDescriptor,
|
|
264
|
+
path: string,
|
|
265
|
+
): void {
|
|
266
|
+
if (!path.startsWith("/") || path.includes("\0")) {
|
|
267
|
+
throw new Error("workspace file path must be an absolute NUL-free path");
|
|
268
|
+
}
|
|
269
|
+
if (path.split("/").some((part) => part === "." || part === "..")) {
|
|
270
|
+
throw new Error("workspace file path must not contain dot segments");
|
|
271
|
+
}
|
|
272
|
+
const root = descriptor.workspacePath.endsWith("/")
|
|
273
|
+
? descriptor.workspacePath
|
|
274
|
+
: `${descriptor.workspacePath}/`;
|
|
275
|
+
if (path !== descriptor.workspacePath && !path.startsWith(root)) {
|
|
276
|
+
throw new Error("workspace file path escapes the task workspace");
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
248
280
|
const ENV_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
249
281
|
|
|
250
282
|
/** Shared admission guard for provider implementations and fake providers. */
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ADR-121 strangler ops: the host-filesystem implementation of the
|
|
3
|
+
* workspace file operations, shared by every provider whose workspace
|
|
4
|
+
* physically lives on the host (docker, apple-container). Behavior is
|
|
5
|
+
* byte-identical to the direct fs access call sites used to perform — the
|
|
6
|
+
* point of the strangler's first phase is that call sites stop KNOWING the
|
|
7
|
+
* workspace is host-side, not that anything moves yet.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
11
|
+
import { dirname, join } from "node:path";
|
|
12
|
+
|
|
13
|
+
import {
|
|
14
|
+
assertWorkspaceScopedPath,
|
|
15
|
+
type TaskEnvironmentDescriptor,
|
|
16
|
+
} from "./types";
|
|
17
|
+
|
|
18
|
+
/** Map an environment-absolute workspace path to its host location. */
|
|
19
|
+
function hostPathFor(
|
|
20
|
+
descriptor: TaskEnvironmentDescriptor,
|
|
21
|
+
hostWorkspaceRoot: string,
|
|
22
|
+
path: string,
|
|
23
|
+
): string {
|
|
24
|
+
assertWorkspaceScopedPath(descriptor, path);
|
|
25
|
+
const relative = path.slice(descriptor.workspacePath.length);
|
|
26
|
+
return join(hostWorkspaceRoot, ...relative.split("/").filter(Boolean));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function hostWorkspaceRead(
|
|
30
|
+
descriptor: TaskEnvironmentDescriptor,
|
|
31
|
+
hostWorkspaceRoot: string,
|
|
32
|
+
path: string,
|
|
33
|
+
): Uint8Array | null {
|
|
34
|
+
try {
|
|
35
|
+
return readFileSync(hostPathFor(descriptor, hostWorkspaceRoot, path));
|
|
36
|
+
} catch (error) {
|
|
37
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return null;
|
|
38
|
+
throw error;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function hostWorkspaceWrite(
|
|
43
|
+
descriptor: TaskEnvironmentDescriptor,
|
|
44
|
+
hostWorkspaceRoot: string,
|
|
45
|
+
path: string,
|
|
46
|
+
bytes: Uint8Array,
|
|
47
|
+
): void {
|
|
48
|
+
const target = hostPathFor(descriptor, hostWorkspaceRoot, path);
|
|
49
|
+
mkdirSync(dirname(target), { recursive: true });
|
|
50
|
+
writeFileSync(target, bytes);
|
|
51
|
+
}
|
package/lib/transcript.ts
CHANGED
|
@@ -14,6 +14,7 @@ import { resolve } from "node:path";
|
|
|
14
14
|
|
|
15
15
|
import { taskWorkspaceDir } from "./env";
|
|
16
16
|
import { rewriteAttachmentRefs } from "./orchestrator";
|
|
17
|
+
import type { TaskEnvironmentHandle } from "./task-environment/types";
|
|
17
18
|
import type { TranscriptTarget } from "../src/protocol";
|
|
18
19
|
|
|
19
20
|
/** Container path agents are pointed at. */
|
|
@@ -26,20 +27,97 @@ const TARGET_FILENAME: Record<TranscriptTarget, string> = {
|
|
|
26
27
|
"chat-front": "chat-front.md",
|
|
27
28
|
};
|
|
28
29
|
|
|
30
|
+
function transcriptEntry(author: string, text: string): string | null {
|
|
31
|
+
// Rewrite cloud attachment URLs to the in-container path so an agent reading
|
|
32
|
+
// the transcript can open referenced files directly.
|
|
33
|
+
const body = rewriteAttachmentRefs(text).trim();
|
|
34
|
+
if (!body) return null;
|
|
35
|
+
return `## ${author}\n\n${body}\n\n`;
|
|
36
|
+
}
|
|
37
|
+
|
|
29
38
|
export function appendTranscript(
|
|
30
39
|
taskId: string,
|
|
31
40
|
author: string,
|
|
32
41
|
text: string,
|
|
33
42
|
targets: TranscriptTarget[],
|
|
34
43
|
): void {
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
const body = rewriteAttachmentRefs(text).trim();
|
|
38
|
-
if (!body) return;
|
|
44
|
+
const entry = transcriptEntry(author, text);
|
|
45
|
+
if (entry === null) return;
|
|
39
46
|
const dir = resolve(taskWorkspaceDir(taskId), ".uai");
|
|
40
47
|
mkdirSync(dir, { recursive: true });
|
|
41
|
-
const entry = `## ${author}\n\n${body}\n\n`;
|
|
42
48
|
for (const target of new Set(targets)) {
|
|
43
49
|
appendFileSync(resolve(dir, TARGET_FILENAME[target]), entry);
|
|
44
50
|
}
|
|
45
51
|
}
|
|
52
|
+
|
|
53
|
+
// ---------------------------------------------------------------------------
|
|
54
|
+
// ADR-121: environment-side transcript for machine-backed tasks. The
|
|
55
|
+
// workspace lives in the machine's world, so the append travels over the
|
|
56
|
+
// environment transport (entry via stdin). A per-task promise chain
|
|
57
|
+
// preserves message order the way the sync host-FS path did implicitly.
|
|
58
|
+
// ---------------------------------------------------------------------------
|
|
59
|
+
|
|
60
|
+
const environmentAppendChains = new Map<string, Promise<void>>();
|
|
61
|
+
|
|
62
|
+
async function appendOneViaEnvironment(
|
|
63
|
+
environment: TaskEnvironmentHandle<unknown>,
|
|
64
|
+
target: TranscriptTarget,
|
|
65
|
+
entry: string,
|
|
66
|
+
): Promise<void> {
|
|
67
|
+
const path = `${environment.descriptor.workspacePath}/.uai/${TARGET_FILENAME[target]}`;
|
|
68
|
+
const proc = await environment.spawn({
|
|
69
|
+
argv: [
|
|
70
|
+
"/bin/sh",
|
|
71
|
+
"-c",
|
|
72
|
+
'mkdir -p "$(dirname "$1")" && cat >> "$1"',
|
|
73
|
+
"append",
|
|
74
|
+
path,
|
|
75
|
+
],
|
|
76
|
+
inheritEnv: [],
|
|
77
|
+
env: {},
|
|
78
|
+
timeoutMs: 30_000,
|
|
79
|
+
maxOutputBytes: 8 * 1024,
|
|
80
|
+
});
|
|
81
|
+
const drains = [
|
|
82
|
+
(async () => {
|
|
83
|
+
for await (const chunk of proc.stdout) void chunk;
|
|
84
|
+
})(),
|
|
85
|
+
(async () => {
|
|
86
|
+
for await (const chunk of proc.stderr) void chunk;
|
|
87
|
+
})(),
|
|
88
|
+
];
|
|
89
|
+
await proc.write(Buffer.from(entry));
|
|
90
|
+
await proc.closeInput();
|
|
91
|
+
const exit = await proc.completion;
|
|
92
|
+
await Promise.allSettled(drains);
|
|
93
|
+
if (exit.exitCode !== 0) {
|
|
94
|
+
throw new Error(`environment transcript append exited ${exit.exitCode}`);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function appendTranscriptViaEnvironment(
|
|
99
|
+
taskId: string,
|
|
100
|
+
environment: TaskEnvironmentHandle<unknown>,
|
|
101
|
+
author: string,
|
|
102
|
+
text: string,
|
|
103
|
+
targets: TranscriptTarget[],
|
|
104
|
+
): Promise<void> {
|
|
105
|
+
const entry = transcriptEntry(author, text);
|
|
106
|
+
if (entry === null) return Promise.resolve();
|
|
107
|
+
const previous = environmentAppendChains.get(taskId) ?? Promise.resolve();
|
|
108
|
+
const next = previous
|
|
109
|
+
.catch(() => {})
|
|
110
|
+
.then(async () => {
|
|
111
|
+
for (const target of new Set(targets)) {
|
|
112
|
+
await appendOneViaEnvironment(environment, target, entry);
|
|
113
|
+
}
|
|
114
|
+
});
|
|
115
|
+
environmentAppendChains.set(taskId, next);
|
|
116
|
+
// Bound the map: forget the chain once it settles as the latest entry.
|
|
117
|
+
void next.finally(() => {
|
|
118
|
+
if (environmentAppendChains.get(taskId) === next) {
|
|
119
|
+
environmentAppendChains.delete(taskId);
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
return next;
|
|
123
|
+
}
|
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -19,14 +19,28 @@ import {
|
|
|
19
19
|
} from "../lib/agent-cli";
|
|
20
20
|
import {
|
|
21
21
|
clearRefresh,
|
|
22
|
+
injectIntoContainer,
|
|
22
23
|
reconcileTaskGitAuth,
|
|
24
|
+
requestAccessToken,
|
|
23
25
|
} from "../lib/github-tokens";
|
|
24
26
|
import {
|
|
25
27
|
prepareTaskGithubGitCredential,
|
|
26
28
|
type TaskGithubGitCredential,
|
|
27
29
|
} from "../lib/github-git-auth";
|
|
28
30
|
import { readAttachment, writeAttachment } from "../lib/attachments";
|
|
29
|
-
import {
|
|
31
|
+
import { readFileSync } from "node:fs";
|
|
32
|
+
import { resolve } from "node:path";
|
|
33
|
+
import { taskProjectWorktree } from "../lib/env";
|
|
34
|
+
import { getDecryptedForProject } from "../lib/host-env";
|
|
35
|
+
import {
|
|
36
|
+
envSchemaNote,
|
|
37
|
+
missingRequiredKeys,
|
|
38
|
+
parseEnvSchema,
|
|
39
|
+
} from "../lib/env-schema";
|
|
40
|
+
import {
|
|
41
|
+
appendTranscript as writeTranscript,
|
|
42
|
+
appendTranscriptViaEnvironment,
|
|
43
|
+
} from "../lib/transcript";
|
|
30
44
|
import { buildTaskDiff } from "../lib/task-diff";
|
|
31
45
|
import {
|
|
32
46
|
deleteHostTask,
|
|
@@ -439,6 +453,37 @@ export const hostCommands: HostCommands = {
|
|
|
439
453
|
runtimeQuarantinedForRecreate = true;
|
|
440
454
|
}
|
|
441
455
|
}
|
|
456
|
+
} else if (
|
|
457
|
+
existingTask?.statusMirror === "running" &&
|
|
458
|
+
existingTask.environmentProvider === "machine"
|
|
459
|
+
) {
|
|
460
|
+
// ADR-121: a machine-backed task revalidates through its own
|
|
461
|
+
// provider — the compose fast path below speaks docker and would
|
|
462
|
+
// misread a machine row. Running machine → persisted success;
|
|
463
|
+
// anything else falls through to provisionTaskEnvironment, whose
|
|
464
|
+
// machine provider owns recovery semantics.
|
|
465
|
+
try {
|
|
466
|
+
const environment = await reconstructPersistedTaskEnvironment(
|
|
467
|
+
existingTask,
|
|
468
|
+
);
|
|
469
|
+
if (environment) {
|
|
470
|
+
const machineStatus = await environment.status();
|
|
471
|
+
if (machineStatus.state === "running") {
|
|
472
|
+
orchestrator.allowChannel(input.task.id);
|
|
473
|
+
return {
|
|
474
|
+
ok: true,
|
|
475
|
+
value: {
|
|
476
|
+
composeProject: existingTask.composeProject ?? "",
|
|
477
|
+
worktreePath: existingTask.worktreePath ?? "",
|
|
478
|
+
},
|
|
479
|
+
};
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
} catch (error) {
|
|
483
|
+
console.warn(
|
|
484
|
+
`[machine] task ${input.task.id}: running-row revalidation failed, continuing to provision: ${error instanceof Error ? error.message : String(error)}`,
|
|
485
|
+
);
|
|
486
|
+
}
|
|
442
487
|
} else if (existingTask?.statusMirror === "running") {
|
|
443
488
|
if (!existingTask.composeProject || !existingTask.worktreePath) {
|
|
444
489
|
return {
|
|
@@ -786,6 +831,17 @@ export const hostCommands: HostCommands = {
|
|
|
786
831
|
result.value.initWarning,
|
|
787
832
|
);
|
|
788
833
|
}
|
|
834
|
+
// ADR-122 phase 1: a project that declares @required env keys with
|
|
835
|
+
// no value on this host gets ONE visible note now — not a blank 500
|
|
836
|
+
// hours later (live 2026-08-25). Best-effort by design: a schema
|
|
837
|
+
// read hiccup must never fail a completed task-up.
|
|
838
|
+
void noteMissingRequiredEnvKeys(input).catch((err) =>
|
|
839
|
+
console.warn(
|
|
840
|
+
`[env-schema] task ${input.task.id}: validation note failed: ${
|
|
841
|
+
err instanceof Error ? err.message : String(err)
|
|
842
|
+
}`,
|
|
843
|
+
),
|
|
844
|
+
);
|
|
789
845
|
// GitHub auth for the container is best-effort (ADR-027) and runs in the
|
|
790
846
|
// background — it must never block or fail task-up. Awaiting it here would
|
|
791
847
|
// couple the command result to a network token-exchange: a slow/hung
|
|
@@ -793,16 +849,33 @@ export const hostCommands: HostCommands = {
|
|
|
793
849
|
// and mark a running task as errored. The reconciler injects + schedules
|
|
794
850
|
// (or emits a system note on failure) on its own; the agents come up
|
|
795
851
|
// regardless and the token lands well before the first `gh` call.
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
)
|
|
805
|
-
|
|
852
|
+
// ADR-121: machine-backed tasks take a direct environment injection —
|
|
853
|
+
// the compose reconciler would probe for an app container, find none,
|
|
854
|
+
// and silently skip. Reconnect re-injection for machines is a
|
|
855
|
+
// follow-up alongside the machine recovery pass.
|
|
856
|
+
if (getHostTask(input.task.id)?.environmentProvider === "machine") {
|
|
857
|
+
void machineTaskGithubAuth(
|
|
858
|
+
input.task.id,
|
|
859
|
+
input.task.ownerUserId,
|
|
860
|
+
).catch((err) =>
|
|
861
|
+
console.warn(
|
|
862
|
+
`[github] machine task ${input.task.id}: auth injection failed: ${
|
|
863
|
+
err instanceof Error ? err.message : String(err)
|
|
864
|
+
}`,
|
|
865
|
+
),
|
|
866
|
+
);
|
|
867
|
+
} else {
|
|
868
|
+
void reconcileTaskGitAuth(
|
|
869
|
+
input.task.id,
|
|
870
|
+
input.task.ownerUserId,
|
|
871
|
+
).catch((err) =>
|
|
872
|
+
console.warn(
|
|
873
|
+
`[github] task ${input.task.id}: post-start reconciliation failed: ${
|
|
874
|
+
err instanceof Error ? err.message : String(err)
|
|
875
|
+
}`,
|
|
876
|
+
),
|
|
877
|
+
);
|
|
878
|
+
}
|
|
806
879
|
} else if (result.code === HostErrorCode.HostUnavailable) {
|
|
807
880
|
// A daemon outage is retryable infrastructure state, not proof that
|
|
808
881
|
// the task itself failed. Undo the optimistic local `starting` mirror
|
|
@@ -1169,6 +1242,20 @@ export const hostCommands: HostCommands = {
|
|
|
1169
1242
|
async attachmentWrite(ctx, input) {
|
|
1170
1243
|
logCommand(ctx, "attachmentWrite", input.taskId, input.filename);
|
|
1171
1244
|
try {
|
|
1245
|
+
// ADR-121 strangler: attachments go through the environment's file
|
|
1246
|
+
// ops, so this call site no longer knows where the workspace lives —
|
|
1247
|
+
// host-side providers implement them as the exact direct fs access
|
|
1248
|
+
// this used to be; machine providers reach over their transport. A
|
|
1249
|
+
// row without a reconstructible environment (legacy) keeps the
|
|
1250
|
+
// host-FS path unchanged.
|
|
1251
|
+
const environment = await reconstructHostTaskEnvironment(input.taskId);
|
|
1252
|
+
if (environment) {
|
|
1253
|
+
await environment.writeWorkspaceFile(
|
|
1254
|
+
attachmentEnvironmentPath(environment, input.filename),
|
|
1255
|
+
Buffer.from(input.dataBase64, "base64"),
|
|
1256
|
+
);
|
|
1257
|
+
return ok(undefined);
|
|
1258
|
+
}
|
|
1172
1259
|
writeAttachment(
|
|
1173
1260
|
input.taskId,
|
|
1174
1261
|
input.filename,
|
|
@@ -1183,7 +1270,12 @@ export const hostCommands: HostCommands = {
|
|
|
1183
1270
|
async attachmentRead(ctx, input) {
|
|
1184
1271
|
logCommand(ctx, "attachmentRead", input.taskId, input.filename);
|
|
1185
1272
|
try {
|
|
1186
|
-
const
|
|
1273
|
+
const environment = await reconstructHostTaskEnvironment(input.taskId);
|
|
1274
|
+
const bytes = environment
|
|
1275
|
+
? await environment.readWorkspaceFile(
|
|
1276
|
+
attachmentEnvironmentPath(environment, input.filename),
|
|
1277
|
+
)
|
|
1278
|
+
: readAttachment(input.taskId, input.filename);
|
|
1187
1279
|
if (!bytes) {
|
|
1188
1280
|
return {
|
|
1189
1281
|
ok: false,
|
|
@@ -1191,7 +1283,7 @@ export const hostCommands: HostCommands = {
|
|
|
1191
1283
|
message: `no such attachment: ${input.filename}`,
|
|
1192
1284
|
};
|
|
1193
1285
|
}
|
|
1194
|
-
return ok({ dataBase64: bytes.toString("base64") });
|
|
1286
|
+
return ok({ dataBase64: Buffer.from(bytes).toString("base64") });
|
|
1195
1287
|
} catch (err) {
|
|
1196
1288
|
return failFromUnknown(err);
|
|
1197
1289
|
}
|
|
@@ -1226,6 +1318,24 @@ export const hostCommands: HostCommands = {
|
|
|
1226
1318
|
async appendTranscript(_ctx, taskId, author, text, targets) {
|
|
1227
1319
|
// Per-message + high-frequency, so no logCommand (avoid log spam).
|
|
1228
1320
|
try {
|
|
1321
|
+
// ADR-121: a machine-backed workspace lives in the machine's world —
|
|
1322
|
+
// the append must travel over the environment transport. Container
|
|
1323
|
+
// tasks keep the direct host-FS write (their workspace is a bind
|
|
1324
|
+
// mount and the sync path is cheaper than a docker exec per message).
|
|
1325
|
+
const task = getHostTask(taskId);
|
|
1326
|
+
if (task?.environmentProvider === "machine") {
|
|
1327
|
+
const environment = await reconstructHostTaskEnvironment(taskId);
|
|
1328
|
+
if (environment) {
|
|
1329
|
+
await appendTranscriptViaEnvironment(
|
|
1330
|
+
taskId,
|
|
1331
|
+
environment,
|
|
1332
|
+
author,
|
|
1333
|
+
text,
|
|
1334
|
+
targets,
|
|
1335
|
+
);
|
|
1336
|
+
return ok(undefined);
|
|
1337
|
+
}
|
|
1338
|
+
}
|
|
1229
1339
|
writeTranscript(taskId, author, text, targets);
|
|
1230
1340
|
return ok(undefined);
|
|
1231
1341
|
} catch (err) {
|
|
@@ -1238,6 +1348,30 @@ function normalizeChannelSpec(input: ChannelEnsureInput): ChannelEnsureInput {
|
|
|
1238
1348
|
return { ...input, workspacePath: "/workspace" };
|
|
1239
1349
|
}
|
|
1240
1350
|
|
|
1351
|
+
/**
|
|
1352
|
+
* ADR-121: gh auth for a machine-backed task, injected over the environment
|
|
1353
|
+
* transport (token via exec stdin — same `gh auth login --with-token` +
|
|
1354
|
+
* `setup-git` gesture as containers, no compose probing). Best-effort like
|
|
1355
|
+
* the compose reconciler: absence of a GitHub connection is a quiet no-op.
|
|
1356
|
+
*/
|
|
1357
|
+
async function machineTaskGithubAuth(
|
|
1358
|
+
taskId: string,
|
|
1359
|
+
userId: string,
|
|
1360
|
+
): Promise<void> {
|
|
1361
|
+
const environment = await reconstructHostTaskEnvironment(taskId);
|
|
1362
|
+
if (!environment) return;
|
|
1363
|
+
const token = await requestAccessToken(userId);
|
|
1364
|
+
if (!token) return;
|
|
1365
|
+
// Machine work is not container work: no docker/apple runtime admission.
|
|
1366
|
+
await injectIntoContainer(
|
|
1367
|
+
taskId,
|
|
1368
|
+
token.accessToken,
|
|
1369
|
+
undefined,
|
|
1370
|
+
() => {},
|
|
1371
|
+
environment,
|
|
1372
|
+
);
|
|
1373
|
+
}
|
|
1374
|
+
|
|
1241
1375
|
async function reconstructHostTaskEnvironment(
|
|
1242
1376
|
taskId: string,
|
|
1243
1377
|
): Promise<TaskEnvironmentHandle<TaskDownResult> | null> {
|
|
@@ -1267,6 +1401,53 @@ function taskDownResultForInput(
|
|
|
1267
1401
|
};
|
|
1268
1402
|
}
|
|
1269
1403
|
|
|
1404
|
+
/** Attachment location inside the environment — same shape every provider
|
|
1405
|
+
* serves (`<workspace>/.uai/attachments/<name>`), basename-hardened exactly
|
|
1406
|
+
* like the legacy host-side safePath. */
|
|
1407
|
+
function attachmentEnvironmentPath(
|
|
1408
|
+
environment: { descriptor: { workspacePath: string } },
|
|
1409
|
+
filename: string,
|
|
1410
|
+
): string {
|
|
1411
|
+
const name = filename.split("/").pop() ?? "";
|
|
1412
|
+
if (name === "" || name === "." || name === ".." || name.includes("\0")) {
|
|
1413
|
+
throw new Error("invalid attachment filename");
|
|
1414
|
+
}
|
|
1415
|
+
return `${environment.descriptor.workspacePath}/.uai/attachments/${name}`;
|
|
1416
|
+
}
|
|
1417
|
+
|
|
1418
|
+
/** ADR-122 phase 1: read each project's committed .env.schema from the
|
|
1419
|
+
* fresh workspace, compare @required keys against the union of values this
|
|
1420
|
+
* host actually injected (env is merged across projects, so any project's
|
|
1421
|
+
* value covers the key), and post one note naming what's missing. */
|
|
1422
|
+
async function noteMissingRequiredEnvKeys(
|
|
1423
|
+
input: TaskLaunchInput,
|
|
1424
|
+
): Promise<void> {
|
|
1425
|
+
const provided = new Set<string>();
|
|
1426
|
+
for (const project of input.projects) {
|
|
1427
|
+
for (const key of Object.keys(getDecryptedForProject(project.id))) {
|
|
1428
|
+
provided.add(key);
|
|
1429
|
+
}
|
|
1430
|
+
}
|
|
1431
|
+
const perProject: Array<{ slug: string; missing: string[] }> = [];
|
|
1432
|
+
for (const project of input.projects) {
|
|
1433
|
+
let content: string;
|
|
1434
|
+
try {
|
|
1435
|
+
content = readFileSync(
|
|
1436
|
+
resolve(taskProjectWorktree(input.task.id, project.slug), ".env.schema"),
|
|
1437
|
+
"utf8",
|
|
1438
|
+
);
|
|
1439
|
+
} catch {
|
|
1440
|
+
continue; // No schema, no opinion — today's behavior, unchanged.
|
|
1441
|
+
}
|
|
1442
|
+
perProject.push({
|
|
1443
|
+
slug: project.slug,
|
|
1444
|
+
missing: missingRequiredKeys(parseEnvSchema(content), provided),
|
|
1445
|
+
});
|
|
1446
|
+
}
|
|
1447
|
+
const note = envSchemaNote(perProject);
|
|
1448
|
+
if (note) getOrchestrator().emitSystemNote(input.task.id, note);
|
|
1449
|
+
}
|
|
1450
|
+
|
|
1270
1451
|
function logCommand(
|
|
1271
1452
|
ctx: CommandContext,
|
|
1272
1453
|
command: keyof HostCommands,
|