@runuai/host 0.9.0 → 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);
@@ -52,6 +52,13 @@ const CLAUDE_EFFORTS = ["low", "medium", "high", "xhigh", "max"];
52
52
  // High is the default reasoning level. Update alongside CLAUDE_EFFORTS.
53
53
  const CLAUDE_DEFAULT_EFFORT = "high";
54
54
 
55
+ // ADR-083: cheap defaults apply at the enforcement boundary as well as in the
56
+ // picker. Explicit per-agent choices still win; an API client that omits them
57
+ // must not accidentally run the communicator on Claude's costly account
58
+ // defaults merely because it bypassed the web recommendation button.
59
+ const CLAUDE_COMMUNICATOR_MODEL = "haiku";
60
+ const CLAUDE_COMMUNICATOR_EFFORT = "low";
61
+
55
62
  // ---------------------------------------------------------------------------
56
63
  // Pure protocol mapping — stream-json line → AgentEvent[].
57
64
  // ---------------------------------------------------------------------------
@@ -177,7 +184,7 @@ function safeStringify(v: unknown): string {
177
184
  // The session.
178
185
  // ---------------------------------------------------------------------------
179
186
 
180
- const CLAUDE_ARGS = [
187
+ const CLAUDE_BASE_ARGS = [
181
188
  "--print",
182
189
  "--input-format",
183
190
  "stream-json",
@@ -190,6 +197,10 @@ const CLAUDE_ARGS = [
190
197
  // 400s when a later turn sees a changed thinking block, so keep each
191
198
  // managed stream-json process in-memory only.
192
199
  "--no-session-persistence",
200
+ ];
201
+
202
+ const CLAUDE_FULL_ACCESS_ARGS = [
203
+ ...CLAUDE_BASE_ARGS,
193
204
  // Full tool access, no per-call prompts. Safe here precisely because
194
205
  // a uai task runs in a throwaway, isolated container operating on a
195
206
  // disposable worktree (ADR-001 / ADR-010) — the container *is* the
@@ -208,6 +219,29 @@ const CLAUDE_ARGS = [
208
219
  "--strict-mcp-config",
209
220
  ];
210
221
 
222
+ /**
223
+ * ADR-083 communicator profile. This is deliberately an engine-level tool
224
+ * boundary, not prompt advice: safe mode suppresses project/user extensions,
225
+ * the explicit tool set contains no shell or mutation primitive, `dontAsk`
226
+ * denies anything outside it in headless mode, and the empty strict MCP config
227
+ * prevents a user-installed server from reintroducing a write/deploy tool.
228
+ */
229
+ const CLAUDE_COMMUNICATOR_ARGS = [
230
+ ...CLAUDE_BASE_ARGS,
231
+ "--safe-mode",
232
+ "--disable-slash-commands",
233
+ "--no-chrome",
234
+ "--permission-mode",
235
+ "dontAsk",
236
+ "--tools",
237
+ "Read,Glob,Grep",
238
+ "--disallowedTools",
239
+ "Bash,Edit,Write,NotebookEdit,Agent,Task,WebFetch,WebSearch",
240
+ "--mcp-config",
241
+ '{"mcpServers":{}}',
242
+ "--strict-mcp-config",
243
+ ];
244
+
211
245
  export class ClaudeSession implements AgentSession {
212
246
  readonly agentId: string;
213
247
  readonly kind: AgentKind = "claude";
@@ -228,6 +262,7 @@ export class ClaudeSession implements AgentSession {
228
262
  agent: RosterAgent;
229
263
  containerName: string;
230
264
  systemPreamble: string;
265
+ executionProfile?: "communicator";
231
266
  agentEnv?: Record<string, string>;
232
267
  }) {
233
268
  this.agentId = args.agent.id;
@@ -236,19 +271,31 @@ export class ClaudeSession implements AgentSession {
236
271
  // project's defaultPrompt) is passed as a real system prompt via
237
272
  // `--append-system-prompt`, so it applies to every turn — not
238
273
  // smuggled into the first user message.
239
- const cliArgs = [...CLAUDE_ARGS];
274
+ const cliArgs = [
275
+ ...(args.executionProfile === "communicator"
276
+ ? CLAUDE_COMMUNICATOR_ARGS
277
+ : CLAUDE_FULL_ACCESS_ARGS),
278
+ ];
240
279
  if (process.env.UAI_CLAUDE_INCLUDE_PARTIAL_MESSAGES === "1") {
241
280
  cliArgs.push("--include-partial-messages");
242
281
  }
243
- // The agent's model (when set) selects which Claude model the CLI
244
- // drives. Without it the CLI uses the account default.
245
- if (args.agent.model) {
246
- cliArgs.push("--model", args.agent.model);
282
+ // Explicit task configuration wins; the communicator uses its declared
283
+ // fast/cheap defaults when the task left either choice unspecified.
284
+ const model =
285
+ args.agent.model ??
286
+ (args.executionProfile === "communicator"
287
+ ? CLAUDE_COMMUNICATOR_MODEL
288
+ : undefined);
289
+ if (model) {
290
+ cliArgs.push("--model", model);
247
291
  }
248
- // The agent's effort (when set) selects the CLI reasoning level. Without
249
- // it the CLI uses its own default.
250
- if (args.agent.effort) {
251
- cliArgs.push("--effort", args.agent.effort);
292
+ const effort =
293
+ args.agent.effort ??
294
+ (args.executionProfile === "communicator"
295
+ ? CLAUDE_COMMUNICATOR_EFFORT
296
+ : undefined);
297
+ if (effort) {
298
+ cliArgs.push("--effort", effort);
252
299
  }
253
300
  if (args.systemPreamble.trim().length > 0) {
254
301
  cliArgs.push("--append-system-prompt", args.systemPreamble);
@@ -261,7 +308,10 @@ export class ClaudeSession implements AgentSession {
261
308
  // ADR-061: durable by default — the CLI is owned by an in-container
262
309
  // runner and survives host restarts (attach resumes it); legacy pipes
263
310
  // behind UAI_DURABLE_SESSIONS=0. Claude is host-side stateless, so a
264
- // live runner can be re-attached (allowAttach).
311
+ // live runner can be re-attached (allowAttach). The communicator profile
312
+ // is the exception: attach is keyed only by task + agent identity, not by
313
+ // execution profile, so a pre-existing full-access runner must be stopped
314
+ // and replaced rather than inherited across this security boundary.
265
315
  this.proc = createAgentTransport({
266
316
  taskId: args.taskId,
267
317
  agentId: this.agentId,
@@ -270,7 +320,7 @@ export class ClaudeSession implements AgentSession {
270
320
  cliArgs,
271
321
  passEnv: ["CLAUDE_CODE_OAUTH_TOKEN", "ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN"],
272
322
  explicitEnv: args.agentEnv ?? {},
273
- allowAttach: true,
323
+ allowAttach: args.executionProfile !== "communicator",
274
324
  kind: "claude",
275
325
  debugLabel: `claude:${this.agentId}`,
276
326
  });
@@ -389,6 +439,14 @@ register({
389
439
  defaultModel: CLAUDE_DEFAULT_MODEL,
390
440
  supportedEfforts: () => [...CLAUDE_EFFORTS],
391
441
  defaultEffort: CLAUDE_DEFAULT_EFFORT,
442
+ executionProfiles: [
443
+ {
444
+ id: "communicator",
445
+ mechanism: "claude-safe-mode-tool-allowlist-v1",
446
+ defaultModel: CLAUDE_COMMUNICATOR_MODEL,
447
+ defaultEffort: CLAUDE_COMMUNICATOR_EFFORT,
448
+ },
449
+ ],
392
450
  // Usable only when a Claude credential is in the env (injected into task
393
451
  // containers at task-up). Gates advertisement (ADR-044 P2).
394
452
  available: () =>
@@ -397,6 +455,20 @@ register({
397
455
  process.env.ANTHROPIC_API_KEY ||
398
456
  process.env.ANTHROPIC_AUTH_TOKEN,
399
457
  ),
400
- create: async ({ taskId, agent, containerName, systemPreamble, agentEnv }) =>
401
- new ClaudeSession({ taskId, agent, containerName, systemPreamble, agentEnv }),
458
+ create: async ({
459
+ taskId,
460
+ agent,
461
+ containerName,
462
+ systemPreamble,
463
+ executionProfile,
464
+ agentEnv,
465
+ }) =>
466
+ new ClaudeSession({
467
+ taskId,
468
+ agent,
469
+ containerName,
470
+ systemPreamble,
471
+ executionProfile,
472
+ agentEnv,
473
+ }),
402
474
  });
@@ -23,7 +23,7 @@ import "./cursor";
23
23
  import "./opencode";
24
24
 
25
25
  import { agentClisReady } from "../standard-image";
26
- import { factoryFor } from "./registry";
26
+ import { factoryFor, supportsExecutionProfile } from "./registry";
27
27
  import type { AgentSession, AgentSessionFactory } from "./types";
28
28
 
29
29
  /** Cap the wait on agentClisReady so a spawn can never hang forever if the
@@ -54,6 +54,14 @@ export const realAgentFactory: AgentSessionFactory = {
54
54
  `no agent adapter registered for kind "${args.agent.kind}"`,
55
55
  );
56
56
  }
57
+ if (
58
+ args.executionProfile &&
59
+ !supportsExecutionProfile(args.agent.kind, args.executionProfile)
60
+ ) {
61
+ throw new Error(
62
+ `agent adapter "${args.agent.kind}" cannot enforce execution profile "${args.executionProfile}"`,
63
+ );
64
+ }
57
65
  // Don't spawn a CLI until the shared-volume agent CLIs are reconciled:
58
66
  // at boot the CLI auto-upgrade briefly removes then reinstalls codex/claude,
59
67
  // and a resume that races that window dies with "No codex executable found
@@ -32,6 +32,13 @@ export interface RegisteredAdapter {
32
32
  supportedEfforts(): string[];
33
33
  /** Preferred effort when the user doesn't pick one. */
34
34
  defaultEffort?: string;
35
+ /** Restricted profiles this adapter can enforce by construction. */
36
+ executionProfiles?: Array<{
37
+ id: string;
38
+ mechanism: string;
39
+ defaultModel?: string;
40
+ defaultEffort?: string;
41
+ }>;
35
42
  /**
36
43
  * Whether this kind is usable on THIS host right now — i.e. its credentials
37
44
  * are present (ADR-044 P2). Gates advertisement: an unavailable kind is left
@@ -52,6 +59,12 @@ export interface AgentKindCapability {
52
59
  defaultModel?: string;
53
60
  supportedEfforts: string[];
54
61
  defaultEffort?: string;
62
+ executionProfiles?: Array<{
63
+ id: string;
64
+ mechanism: string;
65
+ defaultModel?: string;
66
+ defaultEffort?: string;
67
+ }>;
55
68
  }
56
69
 
57
70
  const adapters = new Map<string, RegisteredAdapter>();
@@ -79,6 +92,15 @@ export function factoryFor(kind: string): AgentSessionFactory | undefined {
79
92
  return adapter ? { create: adapter.create } : undefined;
80
93
  }
81
94
 
95
+ /** Whether the adapter declares an engine-enforced execution profile. */
96
+ export function supportsExecutionProfile(kind: string, profileId: string): boolean {
97
+ return Boolean(
98
+ adapters
99
+ .get(kind)
100
+ ?.executionProfiles?.some((profile) => profile.id === profileId),
101
+ );
102
+ }
103
+
82
104
  /**
83
105
  * The `agentKinds` capability slice, derived from the registered adapters.
84
106
  * Each adapter's `supportedModels()` / `supportedEfforts()` is evaluated here.
@@ -99,6 +121,11 @@ export function capabilities(): AgentKindCapability[] {
99
121
  if (adapter.defaultEffort !== undefined) {
100
122
  out.defaultEffort = adapter.defaultEffort;
101
123
  }
124
+ if (adapter.executionProfiles && adapter.executionProfiles.length > 0) {
125
+ out.executionProfiles = adapter.executionProfiles.map((profile) => ({
126
+ ...profile,
127
+ }));
128
+ }
102
129
  return out;
103
130
  });
104
131
  }
@@ -165,6 +165,9 @@ export interface AgentSessionFactory {
165
165
  containerName: string;
166
166
  /** Initial briefing — project.defaultPrompt — sent on session start. */
167
167
  systemPreamble: string;
168
+ /** Host-derived restricted execution policy (ADR-083). Never supplied by
169
+ * the browser or stored on the roster entry. */
170
+ executionProfile?: "communicator";
168
171
  /** ADR-048: extra per-agent env for the `docker exec` (e.g. this agent's
169
172
  * own UAI_TASK_TOKEN so its `uai` CLI carries only its own permissions). */
170
173
  agentEnv?: Record<string, string>;