@runuai/host 0.9.1 → 0.9.2

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.
@@ -37,33 +37,52 @@ else
37
37
  fi
38
38
 
39
39
  # ---------------------------------------------------------------------------
40
- # 0. GitHub auth for in-container git (ADR-027). git rides the host's uai SSH
41
- # identity (docker-cp'd to ~/.ssh, registered on GitHub for auth + signing),
42
- # NOT an HTTPS token. Route every GitHub remote including https:// ones —
43
- # over SSH so a project's clone scheme doesn't matter, and trust github's
44
- # host key non-interactively so a fresh container's first push doesn't stall
45
- # on a prompt. `gh` is separate: it authenticates from its own config file,
46
- # which the host writes via `gh auth login --with-token` with the user's
47
- # short-lived token. We must NOT set GH_TOKEN in the env — `gh` would prefer
48
- # it over that stored credential (and re-attribute every PR to it).
40
+ # 0. Git transport selection (ADR-027). A connected user's App token is the
41
+ # primary clone/fetch/push credential over HTTPS; the host writes it into
42
+ # gh's private config and runs `gh auth setup-git` before agents start. SSH
43
+ # transport is only the no-GitHub-connection fallback. The SSH key remains
44
+ # useful in either mode for commit/tag signing.
49
45
  # ---------------------------------------------------------------------------
50
46
 
51
- if [ -f "$HOME/.ssh/id_ed25519" ]; then
52
- log "routing git GitHub remotes over the uai SSH identity"
53
- git config --global url."git@github.com:".insteadOf "https://github.com/"
54
- git config --global core.sshCommand "ssh -o StrictHostKeyChecking=accept-new"
47
+ if [ "${UAI_SKIP_GIT_TRANSPORT:-0}" = "1" ]; then
48
+ # Recovery/connect/disconnect reconciles the current credential immediately
49
+ # before invoking us. Do not overwrite that live decision with the static
50
+ # transport marker captured when Compose was first created.
51
+ log "keeping the reconciled GitHub transport"
52
+ elif [ "${UAI_GIT_TRANSPORT:-anonymous}" = "ssh" ]; then
53
+ # Remove a prior token-mode reverse rewrite if this container is being
54
+ # repaired after disconnect, then route canonical HTTPS origins over SSH.
55
+ /usr/bin/git config --global --unset-all url."https://github.com/".insteadOf \
56
+ >/dev/null 2>&1 || true
57
+ /usr/bin/git config --global url."git@github.com:".insteadOf "https://github.com/"
58
+ /usr/bin/git config --global core.sshCommand "/usr/bin/ssh -o StrictHostKeyChecking=accept-new"
59
+ log "using SSH as the GitHub transport fallback"
60
+ else
61
+ # Heal containers created by the old SSH-only policy. Canonical origins are
62
+ # HTTPS; reverse legacy SSH URLs (including submodules) through gh as well.
63
+ /usr/bin/git config --global --unset-all url."git@github.com:".insteadOf \
64
+ >/dev/null 2>&1 || true
65
+ /usr/bin/git config --global --unset-all core.sshCommand >/dev/null 2>&1 || true
66
+ /usr/bin/git config --global --replace-all url."https://github.com/".insteadOf \
67
+ "git@github.com:"
68
+ /usr/bin/git config --global --add url."https://github.com/".insteadOf \
69
+ "ssh://git@github.com/"
70
+ if [ "${UAI_GIT_TRANSPORT:-}" = "https" ]; then
71
+ log "using the connected GitHub credential for HTTPS Git"
72
+ else
73
+ log "using anonymous HTTPS Git for public repositories"
74
+ fi
55
75
  fi
56
76
 
57
77
  # Signed commits (policy): when the uai SSH identity is present, configure git
58
78
  # to SSH-sign every commit + tag with it. The pubkey is registered on GitHub
59
- # (via `setup-identity`) so commits show as Verified. The same key also carries
60
- # git push over SSH (configured above).
79
+ # so commits show as Verified. Signing is independent of Git transport.
61
80
  if [ -f "$HOME/.ssh/id_ed25519.pub" ]; then
62
81
  log "enabling SSH commit signing with the uai identity"
63
- git config --global gpg.format ssh
64
- git config --global user.signingkey "$HOME/.ssh/id_ed25519.pub"
65
- git config --global commit.gpgsign true
66
- git config --global tag.gpgsign true
82
+ /usr/bin/git config --global gpg.format ssh
83
+ /usr/bin/git config --global user.signingkey "$HOME/.ssh/id_ed25519.pub"
84
+ /usr/bin/git config --global commit.gpgsign true
85
+ /usr/bin/git config --global tag.gpgsign true
67
86
  fi
68
87
 
69
88
  # Attribution policy: never co-author with the agents. Claude Code honors
package/lib/agent.ts CHANGED
@@ -33,6 +33,11 @@ import { getHostTask } from "./runtime-state";
33
33
  import { removeTaskIdentity, writeTaskIdentity } from "./ssh";
34
34
  import type { TaskDownInput, TaskLaunchInput } from "../src/protocol";
35
35
 
36
+ interface TaskUpCredentials {
37
+ /** Path to the task owner's private, ephemeral Git credential cache. */
38
+ githubCredentialSocket?: string;
39
+ }
40
+
36
41
  // ---------------------------------------------------------------------------
37
42
  // Locate the agent scripts. They ship inside the package, so by default we
38
43
  // resolve them relative to this module — which works identically in a repo
@@ -136,11 +141,12 @@ async function runAgent<T extends z.ZodTypeAny>(
136
141
  dataSchema: T,
137
142
  commandDbPath: string,
138
143
  extraEnv: Record<string, string> = {},
144
+ stdinPayload?: string,
139
145
  ): Promise<z.infer<T>> {
140
146
  const scriptPath = resolve(agentDir(), scriptName);
141
147
 
142
148
  const child = spawn(scriptPath, args, {
143
- stdio: ["ignore", "pipe", "pipe"],
149
+ stdio: [stdinPayload === undefined ? "ignore" : "pipe", "pipe", "pipe"],
144
150
  env: {
145
151
  ...process.env,
146
152
  UAI_DB_PATH: commandDbPath,
@@ -151,12 +157,19 @@ async function runAgent<T extends z.ZodTypeAny>(
151
157
 
152
158
  let stdoutBuf = "";
153
159
  let stderrBuf = "";
154
- child.stdout.on("data", (chunk) => {
160
+ child.stdout?.on("data", (chunk) => {
155
161
  stdoutBuf += chunk.toString("utf8");
156
162
  });
157
- child.stderr.on("data", (chunk) => {
163
+ child.stderr?.on("data", (chunk) => {
158
164
  stderrBuf += chunk.toString("utf8");
159
165
  });
166
+ if (stdinPayload !== undefined && child.stdin) {
167
+ // A script that exits before consuming stdin may close the pipe first.
168
+ // The process exit/error below is authoritative; do not turn EPIPE into an
169
+ // unhandled stream error in the host agent.
170
+ child.stdin.on("error", () => {});
171
+ child.stdin.end(stdinPayload);
172
+ }
160
173
 
161
174
  const exitCode: number = await new Promise((res, rej) => {
162
175
  child.on("error", rej);
@@ -267,27 +280,39 @@ export function toolStderrDetail(stderr: string): string {
267
280
  // ---------------------------------------------------------------------------
268
281
 
269
282
  export const agent = {
270
- async taskUp(input: TaskLaunchInput): Promise<TaskUpResult> {
283
+ async taskUp(
284
+ input: TaskLaunchInput,
285
+ credentials: TaskUpCredentials = {},
286
+ ): Promise<TaskUpResult> {
271
287
  const commandDbPath = createTaskUpCommandDb(input);
272
- // Materialize the creator's per-user SSH key so task-up.sh clones + pushes
273
- // as them (ADR-029); null → task-up.sh falls back to the operator identity.
288
+ // Materialize the creator's per-user SSH key for commit signing and the
289
+ // explicit no-GitHub-connection transport fallback (ADR-029).
274
290
  const identityDir = writeTaskIdentity(input.task.id, input.task.ownerUserId);
275
- // Per-(project, key) env var VALUES: each project on the task contributes
276
- // its own decrypted values, which go straight into the child ENV (never
277
- // args/logs/the compose YAML). task-up.sh already renders a `${KEY:-}`
278
- // pass-through for each project's DECLARED keys, so docker interpolates the
279
- // value from this child's env at up-time mirroring CLAUDE_CODE_OAUTH_TOKEN
280
- // and secret-blind end to end (ADR-015). On a key collision across projects
281
- // the LEAD project (position 0) wins: iterate in DESCENDING position order
282
- // so position-0's assignments are applied LAST and overwrite the rest.
283
- const extraEnv: Record<string, string> = {};
291
+ // Per-(project, key) env values must NEVER enter task-up's host process
292
+ // environment. Besides ordinary app names, projects can declare names such
293
+ // as BASH_ENV, PATH, GIT_CONFIG_*, or UAI_*; exposing those before the host
294
+ // clone would let container-owned configuration influence host commands or
295
+ // read another credential boundary. Send one JSON object over stdin
296
+ // instead. task-up feeds it directly to Compose as an in-memory override,
297
+ // so values reach only the task container and are never written to disk.
298
+ // On a key collision the LEAD project (position 0) wins: iterate in
299
+ // descending position order so position-0 assignments apply last.
300
+ const projectEnv: Record<string, string> = {};
284
301
  const orderedProjects = [...input.projects].sort(
285
302
  (a, b) => b.position - a.position,
286
303
  );
287
304
  for (const project of orderedProjects) {
288
- Object.assign(extraEnv, getDecryptedForProject(project.id));
305
+ Object.assign(projectEnv, getDecryptedForProject(project.id));
289
306
  }
307
+ const extraEnv: Record<string, string> = {};
290
308
  if (identityDir) extraEnv.UAI_TASK_IDENTITY_DIR = identityDir;
309
+ // Only a non-secret socket path crosses into Bash. The token itself was
310
+ // seeded over stdin and never enters argv, env, project data, Compose, or
311
+ // the task container.
312
+ if (credentials.githubCredentialSocket) {
313
+ extraEnv.UAI_GITHUB_CREDENTIAL_SOCKET =
314
+ credentials.githubCredentialSocket;
315
+ }
291
316
  try {
292
317
  return await runAgent(
293
318
  "task-up.sh",
@@ -295,6 +320,7 @@ export const agent = {
295
320
  TaskUpData,
296
321
  commandDbPath,
297
322
  extraEnv,
323
+ JSON.stringify(projectEnv),
298
324
  );
299
325
  } finally {
300
326
  removeCommandDb(commandDbPath);