@guuey/host 0.1.0

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.
Files changed (48) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +47 -0
  3. package/dist/agent-entry.d.ts +38 -0
  4. package/dist/agent-entry.d.ts.map +1 -0
  5. package/dist/agent-entry.js +87 -0
  6. package/dist/boot-context.d.ts +50 -0
  7. package/dist/boot-context.d.ts.map +1 -0
  8. package/dist/boot-context.js +22 -0
  9. package/dist/creds.d.ts +26 -0
  10. package/dist/creds.d.ts.map +1 -0
  11. package/dist/creds.js +45 -0
  12. package/dist/frameworks/claude-options.d.ts +149 -0
  13. package/dist/frameworks/claude-options.d.ts.map +1 -0
  14. package/dist/frameworks/claude-options.js +208 -0
  15. package/dist/frameworks/claude-runner.d.ts +3 -0
  16. package/dist/frameworks/claude-runner.d.ts.map +1 -0
  17. package/dist/frameworks/claude-runner.js +33 -0
  18. package/dist/frameworks/claude.d.ts +72 -0
  19. package/dist/frameworks/claude.d.ts.map +1 -0
  20. package/dist/frameworks/claude.js +80 -0
  21. package/dist/frameworks/google-adk.d.ts +105 -0
  22. package/dist/frameworks/google-adk.d.ts.map +1 -0
  23. package/dist/frameworks/google-adk.js +269 -0
  24. package/dist/frameworks/openai-runner.d.ts +3 -0
  25. package/dist/frameworks/openai-runner.d.ts.map +1 -0
  26. package/dist/frameworks/openai-runner.js +37 -0
  27. package/dist/frameworks/openai.d.ts +65 -0
  28. package/dist/frameworks/openai.d.ts.map +1 -0
  29. package/dist/frameworks/openai.js +207 -0
  30. package/dist/index.d.ts +31 -0
  31. package/dist/index.d.ts.map +1 -0
  32. package/dist/index.js +134 -0
  33. package/dist/options.d.ts +173 -0
  34. package/dist/options.d.ts.map +1 -0
  35. package/dist/options.js +248 -0
  36. package/dist/preamble.d.ts +16 -0
  37. package/dist/preamble.d.ts.map +1 -0
  38. package/dist/preamble.js +34 -0
  39. package/dist/run-openai.d.ts +65 -0
  40. package/dist/run-openai.d.ts.map +1 -0
  41. package/dist/run-openai.js +207 -0
  42. package/dist/run.d.ts +72 -0
  43. package/dist/run.d.ts.map +1 -0
  44. package/dist/run.js +80 -0
  45. package/dist/sdk-version.d.ts +5 -0
  46. package/dist/sdk-version.d.ts.map +1 -0
  47. package/dist/sdk-version.js +56 -0
  48. package/package.json +78 -0
package/dist/index.js ADDED
@@ -0,0 +1,134 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * `@guuey/host` — the universal config-driven Guuey worker.
4
+ *
5
+ * Reads the resolved agent.json snapshot (`GUUEY_AGENT_SNAPSHOT`), lazily
6
+ * loads the runner for `snapshot.framework`, and drives one turn per
7
+ * `invoke`, emitting each framework-native event to fd-3 as a `native`
8
+ * WorkerEvent. The Router dispatches those to the matching
9
+ * `@silverprotocol/<framework>` normalizer. On the runner's result it emits
10
+ * `done`; on a throw it emits `error`; on `shutdown` (or stdin EOF) it exits.
11
+ *
12
+ * **Thin-wrapper contract:** the agent runtimes
13
+ * (`@anthropic-ai/claude-agent-sdk`, `@openai/agents`, `@google/adk`) are
14
+ * OPTIONAL PEER dependencies — the host is orchestration; the runtime is the
15
+ * installer's declaration (the platform pins them in
16
+ * `@guuey-private/host-shared`, the `/shared` composition package). Runners
17
+ * are loaded via dynamic `import()` so a pod only ever loads the one SDK its
18
+ * framework needs; a missing peer fails with an actionable install hint, not
19
+ * a bare module-resolution stack.
20
+ *
21
+ * Runs inside bubblewrap with NO IRSA — it never mints federation tokens. A
22
+ * federated MCP server's credentials are read from the well-known path the
23
+ * Router-side credential broker wrote: `<sessionDir>/.guuey/credentials/<srv>.json`.
24
+ *
25
+ * Protocol wiring (per `@guuey/worker`): Router→Worker control on fd 0 (stdin),
26
+ * Worker→Router events on fd 3. We use the raw emitter (NOT the text-only
27
+ * `serve(handler)`) because the host emits `native`.
28
+ */
29
+ import { createWriteStream } from "node:fs";
30
+ import { createEmitter, isInvoke, isShutdown, parseControl, } from "@guuey/worker";
31
+ /**
32
+ * Per-framework runner registry: module path + the peer package whose absence
33
+ * is the overwhelmingly likely cause of an import failure (the install hint).
34
+ */
35
+ const RUNNERS = {
36
+ "claude-agent-sdk": { module: "./frameworks/claude-runner.js", peer: "@anthropic-ai/claude-agent-sdk", agentEntry: false },
37
+ "openai-agents-sdk": { module: "./frameworks/openai-runner.js", peer: "@openai/agents", agentEntry: false },
38
+ "google-adk": { module: "./frameworks/google-adk.js", peer: "@google/adk", agentEntry: true },
39
+ };
40
+ /**
41
+ * Graceful mode (`GUUEY_AGENT_ENTRY`) is per-runner: a framework whose runner
42
+ * ignores the entry env must FAIL LOUDLY, not silently run the no-code
43
+ * snapshot instead of the dev's module (review finding — "non-goal" means
44
+ * rejected, not ignored). Boot-time check, same posture as a missing peer.
45
+ */
46
+ export function assertGracefulSupport(framework, agentEntryEnv) {
47
+ if (agentEntryEnv === undefined || agentEntryEnv === "")
48
+ return;
49
+ const entry = RUNNERS[framework];
50
+ if (entry !== undefined && !entry.agentEntry) {
51
+ throw new Error(`@guuey/host: guuey.json#agent.entry (graceful mode) is not supported for framework "${framework}" yet — ` +
52
+ `only ${Object.entries(RUNNERS)
53
+ .filter(([, r]) => r.agentEntry)
54
+ .map(([f]) => f)
55
+ .join(", ")} run a dev-exported agent module. ` +
56
+ `Use a full worker (serveNative) for this framework, or remove agent.entry.`);
57
+ }
58
+ }
59
+ /** Parse the boot snapshot — the resolved `agent` section (a {@link GuueyAgent}). */
60
+ function readSnapshot() {
61
+ const raw = process.env.GUUEY_AGENT_SNAPSHOT ?? "{}";
62
+ const parsed = JSON.parse(raw);
63
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
64
+ throw new Error("@guuey/host: GUUEY_AGENT_SNAPSHOT must be a JSON object (the agent section).");
65
+ }
66
+ return parsed;
67
+ }
68
+ /**
69
+ * Load the runner for `framework`, translating a module-resolution failure
70
+ * into the actionable missing-peer message (the runtimes are optional peers —
71
+ * the host deliberately does not bundle them).
72
+ */
73
+ export async function loadRunner(framework) {
74
+ const entry = RUNNERS[framework];
75
+ if (!entry) {
76
+ throw new Error(`@guuey/host: unknown framework "${framework}" — supported: ${Object.keys(RUNNERS).join(", ")}`);
77
+ }
78
+ try {
79
+ const mod = (await import(entry.module));
80
+ return mod.createRunner();
81
+ }
82
+ catch (err) {
83
+ const code = err.code;
84
+ if (code === "ERR_MODULE_NOT_FOUND" || code === "MODULE_NOT_FOUND") {
85
+ throw new Error(`@guuey/host: cannot load the "${framework}" runner — its runtime is an optional peer. ` +
86
+ `Install ${entry.peer} next to @guuey/host to run this framework. (${String(err)})`);
87
+ }
88
+ throw err;
89
+ }
90
+ }
91
+ /** Async-iterate NDJSON lines off stdin. */
92
+ async function* lines(input) {
93
+ let buf = "";
94
+ for await (const chunk of input) {
95
+ buf += typeof chunk === "string" ? chunk : chunk.toString("utf8");
96
+ let nl = buf.indexOf("\n");
97
+ while (nl !== -1) {
98
+ const line = buf.slice(0, nl).trim();
99
+ buf = buf.slice(nl + 1);
100
+ if (line.length > 0)
101
+ yield line;
102
+ nl = buf.indexOf("\n");
103
+ }
104
+ }
105
+ const tail = buf.trim();
106
+ if (tail.length > 0)
107
+ yield tail;
108
+ }
109
+ /** The worker loop: per `invoke` run the framework runner; on `shutdown`/EOF exit. */
110
+ async function main() {
111
+ const snapshot = readSnapshot();
112
+ const framework = snapshot.framework ?? "claude-agent-sdk";
113
+ // fd 3 is the write end of the pipe the Router created at spawn.
114
+ const out = createWriteStream("", { fd: 3 });
115
+ const emit = createEmitter(out);
116
+ // Load ONCE at boot — the pod runs one framework for its whole life, and a
117
+ // missing peer must fail the first turn loudly, not lazily mid-session.
118
+ assertGracefulSupport(framework, process.env.GUUEY_AGENT_ENTRY);
119
+ const runner = await loadRunner(framework);
120
+ for await (const line of lines(process.stdin)) {
121
+ const msg = parseControl(line);
122
+ if (isShutdown(msg))
123
+ break;
124
+ if (!isInvoke(msg))
125
+ continue;
126
+ const { type: _type, ...turn } = msg;
127
+ // Turns are sequential — await this invoke before reading the next line.
128
+ await runner.runTurn(snapshot, turn, emit);
129
+ }
130
+ }
131
+ main().catch((err) => {
132
+ process.stderr.write(`@guuey/host fatal: ${err instanceof Error ? err.stack ?? err.message : String(err)}\n`);
133
+ process.exit(1);
134
+ });
@@ -0,0 +1,173 @@
1
+ /**
2
+ * Snapshot → Claude Agent SDK `Options` construction. Lifted from
3
+ * `backend/services/nocode-runtime/src/agent-runner.ts` (the pure-logic half),
4
+ * with the B2-mcp amendment: `@guuey/host` is a THIN CRED-DIR READER. All MCP
5
+ * resolution (default, federation, mint, env-substitution) now lives once on the
6
+ * Router-side credential broker. The worker just reads
7
+ * `<sessionDir>/.guuey/credentials/*.json` (via ctx.listCredentials) and shapes
8
+ * each entry into the framework-neutral `SdkMcpServer` map.
9
+ *
10
+ * Two responsibilities:
11
+ *
12
+ * 1. **Snapshot → SDK options mapping.** Translates the agent.json shape
13
+ * (model, allowedTools, maxTurns, GuueyFS binding) and the cred-dir contents
14
+ * into the Claude Agent SDK's `mcpServers` + `allowedTools` + `maxTurns`.
15
+ * 2. **Cred-dir mapping.** `resolveMcpServers(ctx)` globs the cred dir via
16
+ * `ctx.listCredentials()` → one `SdkMcpServer` per file; ALL the old
17
+ * federation/default/isGguiUrl/env-sub logic is DELETED (Router-side now).
18
+ *
19
+ * OSS-legality: this package imports ONLY `@anthropic-ai/claude-agent-sdk`,
20
+ * `@guuey/worker`, `@guuey/config`, and Node built-ins.
21
+ */
22
+ import type { CanUseTool, Options, SDKMessage } from "@anthropic-ai/claude-agent-sdk";
23
+ import type { Fs, HistoryMessage, JsonValue } from "@guuey/worker";
24
+ import { type GuueyAgent } from "@guuey/config";
25
+ export type { SDKMessage };
26
+ /**
27
+ * Env-var names the Router injects so agent code reaches the home/app layers
28
+ * portably. Host-owned copies of `@guuey/fs`'s `ENV_HOME_DIR`/`ENV_APP_DIR`
29
+ * (trivial string literals — not imported, to keep this package OSS-legal).
30
+ */
31
+ export declare const ENV_HOME_DIR = "GUUEY_HOME_DIR";
32
+ export declare const ENV_APP_DIR = "GUUEY_APP_DIR";
33
+ /**
34
+ * The credential file the Router-side broker writes per invoke at
35
+ * `<sessionDir>/.guuey/credentials/<server>.json`. Shape from spec §7.1 (B2-mcp).
36
+ * `transport` is required so the worker knows which SDK arm to build without
37
+ * consulting the snapshot — the broker owns ALL resolution including transport.
38
+ */
39
+ export interface CredentialFile {
40
+ /** The resolved MCP URL (may be scoped `<host>/apps/<id>` for federated ggui). */
41
+ url: string;
42
+ /** Transport the broker selected for this server. */
43
+ transport: "http" | "sse";
44
+ /** Headers to forward — typically `{ authorization: 'Bearer <token>' }`. */
45
+ headers: Record<string, string>;
46
+ /** ISO expiry; informational for the worker (the Router refreshes per invoke). */
47
+ expiresAt?: string;
48
+ }
49
+ /**
50
+ * SDK's `mcpServers` value shape — recreated structurally rather than imported
51
+ * because the SDK ships it as part of `Options['mcpServers']` (a record-of-union)
52
+ * and pulling out a single arm is awkward in TS.
53
+ */
54
+ export type SdkMcpServer = {
55
+ type: "http";
56
+ url: string;
57
+ headers?: Record<string, string>;
58
+ alwaysLoad?: boolean;
59
+ } | {
60
+ type: "sse";
61
+ url: string;
62
+ headers?: Record<string, string>;
63
+ alwaysLoad?: boolean;
64
+ } | {
65
+ type: "stdio";
66
+ command: string;
67
+ args?: string[];
68
+ alwaysLoad?: boolean;
69
+ };
70
+ /**
71
+ * One prior memory record fed into the `<thread_memory>` preamble. Host-owned,
72
+ * minimal projection of `@silverprotocol/core`'s `AgMemoryRecord` (the preamble
73
+ * reads only `key`/`value`). Not imported — OSS-legality.
74
+ */
75
+ export interface PriorMemoryRecord {
76
+ key?: string;
77
+ value: JsonValue;
78
+ }
79
+ /**
80
+ * Per-invoke context `buildOptions` needs beyond the static snapshot. Sourced by
81
+ * the worker loop from the `invoke` control message + boot env.
82
+ */
83
+ export interface BuildOptionsContext {
84
+ /** The user message — passed to `query({ prompt })` by the caller. */
85
+ input: string;
86
+ /** Router-vouched end-user identity. */
87
+ identity: {
88
+ userId: string;
89
+ authMode: "anonymous" | "authenticated";
90
+ };
91
+ /**
92
+ * Anthropic API key — used for local-dev / off-sandbox fallback when
93
+ * `baseUrl` + `authToken` are absent. One of (`baseUrl`+`authToken`) or
94
+ * `apiKey` must be provided; `buildOptions` throws if neither is present.
95
+ */
96
+ apiKey?: string;
97
+ /**
98
+ * Loopback proxy base URL for the managed-LLM broker (`ANTHROPIC_BASE_URL`).
99
+ * When present together with `authToken`, the Claude CLI subprocess is routed
100
+ * through the broker; the real API key is intentionally omitted from
101
+ * `options.env` so it cannot leak to agent code.
102
+ */
103
+ baseUrl?: string;
104
+ /**
105
+ * Opaque session token for the loopback proxy (`ANTHROPIC_AUTH_TOKEN`).
106
+ * Required when `baseUrl` is set; ignored when only `apiKey` is present.
107
+ */
108
+ authToken?: string;
109
+ /**
110
+ * Per-session GuueyFS layer mounts (the invoke's `fs`). When present, the
111
+ * invoke binds `cwd`=session, exposes home+app as `additionalDirectories`,
112
+ * enables the file tools, and injects `GUUEY_*` env. Absent → no FS binding.
113
+ */
114
+ fs?: Fs;
115
+ /** Recent conversation window for the `<conversation_history>` preamble. */
116
+ history?: HistoryMessage[];
117
+ /** Thread-scoped memory for the `<thread_memory>` preamble (the §1.4 push). */
118
+ priorMemory?: PriorMemoryRecord[];
119
+ /** Prior working-state blob for the `<working_state>` preamble. */
120
+ priorState?: JsonValue;
121
+ /**
122
+ * Returns every credential the Router broker wrote to
123
+ * `<sessionDir>/.guuey/credentials/` this invoke — one `{name, cred}` per
124
+ * usable MCP server. `name` is the filename stem (server name); `cred` is the
125
+ * parsed `CredentialFile`. Injected so option-building stays pure (no disk).
126
+ */
127
+ listCredentials: () => Array<{
128
+ name: string;
129
+ cred: CredentialFile;
130
+ }>;
131
+ /** Cancels the in-flight `query` when the client disconnects. */
132
+ abortController?: AbortController;
133
+ }
134
+ /**
135
+ * Build the Claude Agent SDK `Options` for one invoke. Pure: all disk/env access
136
+ * is injected via {@link BuildOptionsContext}. Throws on an unresolved `{file}`
137
+ * system prompt or a missing API key (the same loud failures the source had).
138
+ */
139
+ export declare function buildOptions(snapshot: GuueyAgent, ctx: BuildOptionsContext): Options;
140
+ /**
141
+ * Auto-allow permission callback. Installed when fs is bound and the operator
142
+ * did NOT pin `claude.permissions.mode`, so the default no-code agent's `Bash`
143
+ * (and the file tools) run prompt-free. Returns `{ behavior: 'allow' }` for
144
+ * every request, passing the input through unchanged.
145
+ *
146
+ * Safe because the model's tool surface is already locked down BEFORE the
147
+ * callback ever fires — `tools`/`allowedTools` cap which tools exist,
148
+ * `settingSources:[]` blocks filesystem-loaded settings, `strictMcpConfig`
149
+ * pins the MCP catalog — and the real OS isolation is the Router's bubblewrap
150
+ * jail this whole process runs inside. The callback only collapses the SDK's
151
+ * final interactive "ask" stage (which would otherwise hang a headless pod);
152
+ * the earlier hook/deny-rule stages of the permission flow still run.
153
+ */
154
+ export declare const autoAllowTool: CanUseTool;
155
+ /**
156
+ * Map the Router-resolved cred files to the framework-neutral SdkMcpServer map.
157
+ * The Router (credential-broker) owns ALL resolution — default, federation, mint,
158
+ * env-substitution; this worker just reads `<session>/.guuey/credentials/*.json`
159
+ * (via ctx.listCredentials) and shapes each entry. Keyed by the server name.
160
+ */
161
+ export declare function resolveMcpServers(ctx: BuildOptionsContext): Record<string, SdkMcpServer>;
162
+ /**
163
+ * Render prior context sections (conversation history, thread memory, working
164
+ * state) as a preamble and prepend to the system prompt. The SDK's `query()`
165
+ * accepts only the current `input` as `prompt`, so feeding context here is how
166
+ * an ephemeral worker gives the model memory across invokes.
167
+ *
168
+ * Empty sections are omitted; if all inputs are empty/undefined the original
169
+ * system prompt is returned unchanged. Exported for unit testing + reuse by the
170
+ * worker loop.
171
+ */
172
+ export declare function withContextPreamble(systemPrompt: string, history: HistoryMessage[] | undefined, priorMemory: PriorMemoryRecord[] | undefined, priorState: JsonValue | undefined): string;
173
+ //# sourceMappingURL=options.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"options.d.ts","sourceRoot":"","sources":["../src/options.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,248 @@
1
+ import { GUUEY_DEFAULT_SYSTEM_PROMPT, defaultModelFor } from "@guuey/config";
2
+ /**
3
+ * Default Claude model — only used when the snapshot omits `model`. Derived
4
+ * from the `@guuey/config` registry (single source of truth per the
5
+ * model-release playbook §8 item A) rather than a bare literal, so a
6
+ * registry default change propagates here automatically.
7
+ */
8
+ const DEFAULT_MODEL = defaultModelFor("claude-agent-sdk");
9
+ /** Default cap on agent-loop turns per user message (matches the SDK/runner default). */
10
+ const DEFAULT_MAX_TURNS = 25;
11
+ /**
12
+ * Env-var names the Router injects so agent code reaches the home/app layers
13
+ * portably. Host-owned copies of `@guuey/fs`'s `ENV_HOME_DIR`/`ENV_APP_DIR`
14
+ * (trivial string literals — not imported, to keep this package OSS-legal).
15
+ */
16
+ export const ENV_HOME_DIR = "GUUEY_HOME_DIR";
17
+ export const ENV_APP_DIR = "GUUEY_APP_DIR";
18
+ /**
19
+ * File tools enabled when GuueyFS layers are bound. `Bash` is added separately
20
+ * (see {@link BASH_TOOL}) so the two are independently testable.
21
+ */
22
+ const FS_TOOLS = ["Read", "Write", "Edit", "Glob", "Grep"];
23
+ /**
24
+ * Real shell exec, enabled alongside the file tools when GuueyFS layers are
25
+ * bound. Unlike the source runner — which enabled `Bash` only when the SDK's OWN
26
+ * `sandbox:{}` block was on — this host runs entirely INSIDE the Router's
27
+ * bubblewrap jail, so the OS isolation is always present whenever fs is bound.
28
+ * The SDK `sandbox:{}` block is therefore NOT set here (it would spawn a nested
29
+ * bubblewrap inside the Router's bwrap); the Router's bwrap IS the isolation.
30
+ */
31
+ const BASH_TOOL = "Bash";
32
+ /**
33
+ * Build the Claude Agent SDK `Options` for one invoke. Pure: all disk/env access
34
+ * is injected via {@link BuildOptionsContext}. Throws on an unresolved `{file}`
35
+ * system prompt or a missing API key (the same loud failures the source had).
36
+ */
37
+ export function buildOptions(snapshot, ctx) {
38
+ const apiKey = ctx.apiKey;
39
+ const baseUrl = ctx.baseUrl;
40
+ const authToken = ctx.authToken;
41
+ // Require either the loopback proxy credentials (hosted/broker path) or a
42
+ // direct API key (local-dev fallback). Neither → fail loudly.
43
+ if (!((baseUrl !== undefined && authToken !== undefined) || apiKey)) {
44
+ throw new Error("@guuey/host: either (baseUrl + authToken) for the managed-LLM proxy, " +
45
+ "or ANTHROPIC_API_KEY for local dev, is required.");
46
+ }
47
+ // Snapshots reach the worker fully resolved — the CLI's loader inlines any
48
+ // `{file}` system prompt before upload. A `{file}` reaching the worker means
49
+ // someone hit the API directly with an un-resolved snapshot; reject loudly.
50
+ if (snapshot.systemPrompt !== undefined && typeof snapshot.systemPrompt !== "string") {
51
+ throw new Error(`@guuey/host: snapshot.systemPrompt must be a resolved string (got ${JSON.stringify(snapshot.systemPrompt)}). The CLI inlines {file} references before upload; workers never read the filesystem.`);
52
+ }
53
+ const mcpServers = resolveMcpServers(ctx);
54
+ const allowedTools = buildAllowedTools(snapshot, Object.keys(mcpServers), Boolean(ctx.fs));
55
+ const systemPrompt = withContextPreamble(snapshot.systemPrompt ?? GUUEY_DEFAULT_SYSTEM_PROMPT, ctx.history, ctx.priorMemory, ctx.priorState);
56
+ const model = snapshot.model ?? DEFAULT_MODEL;
57
+ const maxTurns = snapshot.runtime?.maxTurns ?? DEFAULT_MAX_TURNS;
58
+ const fs = ctx.fs;
59
+ // Build the subprocess env. Two mutually-exclusive paths:
60
+ //
61
+ // - Proxy path (baseUrl + authToken present): route the Claude CLI
62
+ // subprocess through the managed-LLM broker. baseUrl + authToken are
63
+ // spread LAST so a builder's snapshot.env cannot override them.
64
+ // ANTHROPIC_API_KEY is intentionally absent — the proxy owns auth.
65
+ // CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC suppresses telemetry pings
66
+ // that would bypass the proxy.
67
+ //
68
+ // - Local-dev fallback (only apiKey present): pass the real key directly.
69
+ // The guard above guarantees apiKey is non-null on this branch.
70
+ // Explicit Record<string, string> annotation prevents TypeScript from widening
71
+ // the ternary to a union `{ K: string } | {}`, which would make spread targets
72
+ // produce optional-undefined keys that conflict with Record<string, string>.
73
+ const fsEnv = fs
74
+ ? { [ENV_HOME_DIR]: fs.home, [ENV_APP_DIR]: fs.app }
75
+ : {};
76
+ let env;
77
+ if (baseUrl !== undefined && authToken !== undefined) {
78
+ env = {
79
+ ...(snapshot.env ?? {}),
80
+ ...fsEnv,
81
+ ANTHROPIC_BASE_URL: baseUrl,
82
+ ANTHROPIC_AUTH_TOKEN: authToken,
83
+ CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: "1",
84
+ };
85
+ }
86
+ else {
87
+ // apiKey is guaranteed non-null by the guard; the if-check narrows the type.
88
+ if (!apiKey)
89
+ throw new Error("unreachable: apiKey guard should have prevented this path");
90
+ env = {
91
+ ANTHROPIC_API_KEY: apiKey,
92
+ ...(snapshot.env ?? {}),
93
+ ...fsEnv,
94
+ };
95
+ }
96
+ // Whether the operator pinned a Claude permission mode in agent.json. When
97
+ // set we forward it verbatim and let the SDK's mode govern the posture; when
98
+ // unset we install the auto-allow `canUseTool` below so the default no-code
99
+ // agent's Bash runs prompt-free (a never-answered prompt would hang the pod).
100
+ const explicitMode = snapshot.claude?.permissions?.mode;
101
+ const options = {
102
+ model,
103
+ mcpServers,
104
+ allowedTools,
105
+ // With GuueyFS layers bound, expose the file tools PLUS real `Bash`; without
106
+ // them this is byte-identical to the source (purely MCP-driven). `Bash` is
107
+ // safe here because the host already runs inside the Router's bubblewrap
108
+ // jail — that bwrap, NOT the SDK's own `sandbox:{}` block, is the isolation.
109
+ tools: fs ? [...FS_TOOLS, BASH_TOOL] : [],
110
+ // Settings isolation. Empty array = "no filesystem settings loaded" — guards
111
+ // against a future SDK change auto-pulling `~/.claude/settings.json` and
112
+ // leaking the operator's logged-in Claude Code MCPs into the tool catalog.
113
+ settingSources: [],
114
+ strictMcpConfig: true,
115
+ maxTurns,
116
+ env,
117
+ systemPrompt,
118
+ // GuueyFS binding (opt-in): session dir as cwd, home+app as extra roots.
119
+ ...(fs ? { cwd: fs.session, additionalDirectories: [fs.home, fs.app] } : {}),
120
+ // Permission posture. Two mutually-exclusive paths:
121
+ //
122
+ // - Operator pinned `claude.permissions.mode` → forward it verbatim; the
123
+ // operator owns the posture (e.g. `acceptEdits`).
124
+ // - No explicit mode + fs bound → install an auto-allow `canUseTool`. In
125
+ // the SDK permission flow (hooks → deny → allow → ask → mode/canUseTool),
126
+ // `default` mode with no callback routes Bash subcommands through an
127
+ // interactive permission prompt — which, in this headless ephemeral pod,
128
+ // no one answers, so the agent would HANG. The auto-allow callback short-
129
+ // circuits that: every tool the model picks (already constrained to
130
+ // `tools`/`allowedTools` + `settingSources:[]` + `strictMcpConfig`) is
131
+ // allowed without a prompt. This is safe precisely because the Router's
132
+ // bubblewrap jail is the real isolation boundary — NOT the SDK's own
133
+ // `sandbox:{}` block (which is intentionally absent to avoid a nested
134
+ // bwrap inside the Router's bwrap). We do NOT use `bypassPermissions`
135
+ // here: it requires `allowDangerouslySkipPermissions` and globally
136
+ // disables hooks/deny-rule evaluation, whereas the callback keeps the
137
+ // deny/hook stages intact while only collapsing the final ask stage.
138
+ ...(explicitMode
139
+ ? { permissionMode: explicitMode }
140
+ : fs
141
+ ? { canUseTool: autoAllowTool }
142
+ : {}),
143
+ ...(ctx.abortController ? { abortController: ctx.abortController } : {}),
144
+ };
145
+ return options;
146
+ }
147
+ /**
148
+ * Auto-allow permission callback. Installed when fs is bound and the operator
149
+ * did NOT pin `claude.permissions.mode`, so the default no-code agent's `Bash`
150
+ * (and the file tools) run prompt-free. Returns `{ behavior: 'allow' }` for
151
+ * every request, passing the input through unchanged.
152
+ *
153
+ * Safe because the model's tool surface is already locked down BEFORE the
154
+ * callback ever fires — `tools`/`allowedTools` cap which tools exist,
155
+ * `settingSources:[]` blocks filesystem-loaded settings, `strictMcpConfig`
156
+ * pins the MCP catalog — and the real OS isolation is the Router's bubblewrap
157
+ * jail this whole process runs inside. The callback only collapses the SDK's
158
+ * final interactive "ask" stage (which would otherwise hang a headless pod);
159
+ * the earlier hook/deny-rule stages of the permission flow still run.
160
+ */
161
+ export const autoAllowTool = (_toolName, input) => Promise.resolve({ behavior: "allow", updatedInput: input });
162
+ /**
163
+ * Map the Router-resolved cred files to the framework-neutral SdkMcpServer map.
164
+ * The Router (credential-broker) owns ALL resolution — default, federation, mint,
165
+ * env-substitution; this worker just reads `<session>/.guuey/credentials/*.json`
166
+ * (via ctx.listCredentials) and shapes each entry. Keyed by the server name.
167
+ */
168
+ export function resolveMcpServers(ctx) {
169
+ const out = {};
170
+ for (const { name, cred } of ctx.listCredentials()) {
171
+ out[name] = {
172
+ type: cred.transport,
173
+ url: cred.url,
174
+ // Declared MCP servers ARE this agent's tool surface. Without
175
+ // alwaysLoad the CLI defers MCP tools behind its ToolSearch built-in —
176
+ // absent here (tools: []) — leaving the model tool-less. Empirically
177
+ // load-bearing; mirrors the scaffold template's worker (see
178
+ // create-agentic-app templates-src claude worker.ts).
179
+ alwaysLoad: true,
180
+ ...(Object.keys(cred.headers).length > 0 ? { headers: cred.headers } : {}),
181
+ };
182
+ }
183
+ return out;
184
+ }
185
+ /**
186
+ * Build the SDK's `allowedTools` array. MCP tools are auto-namespaced by the SDK
187
+ * as `mcp__<server>__<tool>`; we can't enumerate the namespaces ahead of time,
188
+ * so the allowlist is necessarily wildcard-ish.
189
+ *
190
+ * - explicit `tools.allowlist` → pass those literal names through.
191
+ * - else → allow every tool from every declared server via `mcp__<server>`.
192
+ *
193
+ * When GuueyFS layers are bound, the file tools AND `Bash` join the allowlist
194
+ * (an allow rule in the SDK permission flow), so the model may use them
195
+ * alongside the MCP allowlist. The auto-allow `canUseTool` (or the operator's
196
+ * pinned mode) governs whether those still prompt — see `buildOptions`.
197
+ */
198
+ function buildAllowedTools(snapshot, declaredServerNames, fsBound) {
199
+ const explicit = snapshot.tools?.allowlist;
200
+ const base = explicit && explicit.length > 0
201
+ ? explicit.slice()
202
+ : declaredServerNames.map((s) => `mcp__${s}`);
203
+ return fsBound ? [...base, ...FS_TOOLS, BASH_TOOL] : base;
204
+ }
205
+ /**
206
+ * Render prior context sections (conversation history, thread memory, working
207
+ * state) as a preamble and prepend to the system prompt. The SDK's `query()`
208
+ * accepts only the current `input` as `prompt`, so feeding context here is how
209
+ * an ephemeral worker gives the model memory across invokes.
210
+ *
211
+ * Empty sections are omitted; if all inputs are empty/undefined the original
212
+ * system prompt is returned unchanged. Exported for unit testing + reuse by the
213
+ * worker loop.
214
+ */
215
+ export function withContextPreamble(systemPrompt, history, priorMemory, priorState) {
216
+ const sections = [];
217
+ if (history && history.length > 0) {
218
+ sections.push([
219
+ "Prior conversation with this user, for context. Continue naturally;",
220
+ "do not repeat it back verbatim.",
221
+ "<conversation_history>",
222
+ ...history.map((m) => `${roleLabel(m.role)}: ${m.text}`),
223
+ "</conversation_history>",
224
+ ].join("\n"));
225
+ }
226
+ if (priorMemory && priorMemory.length > 0) {
227
+ sections.push([
228
+ "Facts you previously recorded for this thread. Treat as known.",
229
+ "<thread_memory>",
230
+ ...priorMemory.map((m) => `${m.key ?? "(unkeyed)"}: ${JSON.stringify(m.value)}`),
231
+ "</thread_memory>",
232
+ ].join("\n"));
233
+ }
234
+ if (priorState !== undefined) {
235
+ sections.push([
236
+ "Your working state carried from the previous turn.",
237
+ "<working_state>",
238
+ JSON.stringify(priorState, null, 2),
239
+ "</working_state>",
240
+ ].join("\n"));
241
+ }
242
+ if (sections.length === 0)
243
+ return systemPrompt;
244
+ return `${sections.join("\n\n")}\n\n${systemPrompt}`;
245
+ }
246
+ function roleLabel(role) {
247
+ return role === "agent" ? "Assistant" : "User";
248
+ }
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Framework-neutral context preamble, shared by every runner.
3
+ *
4
+ * Render prior context sections (conversation history, thread memory, working
5
+ * state) as a preamble and prepend to the system prompt. Ephemeral workers
6
+ * accept only the current `input` as the turn prompt, so feeding context here
7
+ * is how they give the model memory across invokes. The rendering is
8
+ * byte-identical across runners (the Python ADK host carried a verbatim port
9
+ * of this function; the JS ADK runner now shares the original).
10
+ *
11
+ * Empty sections are omitted; if all inputs are empty/undefined the original
12
+ * system prompt is returned unchanged.
13
+ */
14
+ import type { HistoryMessage, JsonValue, PriorMemoryRecord } from "@guuey/worker";
15
+ export declare function withContextPreamble(systemPrompt: string, history: HistoryMessage[] | undefined, priorMemory: PriorMemoryRecord[] | undefined, priorState: JsonValue | undefined): string;
16
+ //# sourceMappingURL=preamble.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"preamble.d.ts","sourceRoot":"","sources":["../src/preamble.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AACH,OAAO,KAAK,EAAE,cAAc,EAAE,SAAS,EAAE,iBAAiB,EAAE,MAAM,eAAe,CAAC;AAElF,wBAAgB,mBAAmB,CACjC,YAAY,EAAE,MAAM,EACpB,OAAO,EAAE,cAAc,EAAE,GAAG,SAAS,EACrC,WAAW,EAAE,iBAAiB,EAAE,GAAG,SAAS,EAC5C,UAAU,EAAE,SAAS,GAAG,SAAS,GAChC,MAAM,CAuCR"}
@@ -0,0 +1,34 @@
1
+ export function withContextPreamble(systemPrompt, history, priorMemory, priorState) {
2
+ const sections = [];
3
+ if (history && history.length > 0) {
4
+ sections.push([
5
+ "Prior conversation with this user, for context. Continue naturally;",
6
+ "do not repeat it back verbatim.",
7
+ "<conversation_history>",
8
+ ...history.map((m) => `${roleLabel(m.role)}: ${m.text}`),
9
+ "</conversation_history>",
10
+ ].join("\n"));
11
+ }
12
+ if (priorMemory && priorMemory.length > 0) {
13
+ sections.push([
14
+ "Facts you previously recorded for this thread. Treat as known.",
15
+ "<thread_memory>",
16
+ ...priorMemory.map((m) => `${m.key ?? "(unkeyed)"}: ${JSON.stringify(m.value)}`),
17
+ "</thread_memory>",
18
+ ].join("\n"));
19
+ }
20
+ if (priorState !== undefined) {
21
+ sections.push([
22
+ "Your working state carried from the previous turn.",
23
+ "<working_state>",
24
+ JSON.stringify(priorState, null, 2),
25
+ "</working_state>",
26
+ ].join("\n"));
27
+ }
28
+ if (sections.length === 0)
29
+ return systemPrompt;
30
+ return `${sections.join("\n\n")}\n\n${systemPrompt}`;
31
+ }
32
+ function roleLabel(role) {
33
+ return role === "agent" ? "Assistant" : "User";
34
+ }