@ravefox/space 0.0.0-stage → 0.2.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 (152) hide show
  1. package/LICENSE +203 -0
  2. package/NOTICE +44 -0
  3. package/README.md +49 -2
  4. package/assets/webhook-sdk/python/bot.py +63 -0
  5. package/assets/webhook-sdk/python/commonly.py +232 -0
  6. package/node_modules/@commonlyai/seats/README.md +25 -0
  7. package/node_modules/@commonlyai/seats/package.json +22 -0
  8. package/node_modules/@commonlyai/seats/src/adapters/hermes.js +264 -0
  9. package/node_modules/@commonlyai/seats/src/adapters/stub.js +24 -0
  10. package/node_modules/@commonlyai/seats/src/contract.js +48 -0
  11. package/node_modules/@commonlyai/seats/src/failures.js +44 -0
  12. package/node_modules/@commonlyai/seats/src/harness-plan.js +329 -0
  13. package/node_modules/@commonlyai/seats/src/index.js +22 -0
  14. package/node_modules/@commonlyai/seats/src/process-group.js +269 -0
  15. package/node_modules/@commonlyai/seats/src/record/format.js +737 -0
  16. package/node_modules/@commonlyai/seats/src/record/otel.js +238 -0
  17. package/node_modules/@commonlyai/seats/src/runtime-observation.js +29 -0
  18. package/node_modules/@commonlyai/seats/src/seat-model.js +17 -0
  19. package/node_modules/@commonlyai/seats/src/supervisor.js +308 -0
  20. package/node_modules/@commonlyai/seats/src/trace/events.js +46 -0
  21. package/node_modules/@commonlyai/seats/src/trace/store.js +276 -0
  22. package/node_modules/@commonlyai/seats/src/turn-processes.js +226 -0
  23. package/node_modules/@commonlyai/seats/src/upstream-refusal.js +418 -0
  24. package/node_modules/@commonlyai/seats/src/valet/env.js +201 -0
  25. package/node_modules/@commonlyai/seats/src/valet/grant.js +123 -0
  26. package/node_modules/@commonlyai/seats/src/valet/permissions.js +213 -0
  27. package/node_modules/@commonlyai/seats/src/wire/provider-proxy.js +529 -0
  28. package/package.json +56 -4
  29. package/skills/commonly/SKILL.md +233 -0
  30. package/src/commands/agent.js +6334 -0
  31. package/src/commands/daemon.js +933 -0
  32. package/src/commands/dev.js +153 -0
  33. package/src/commands/doctor.js +141 -0
  34. package/src/commands/inbox.js +330 -0
  35. package/src/commands/login.js +194 -0
  36. package/src/commands/mcp.js +56 -0
  37. package/src/commands/pod.js +120 -0
  38. package/src/commands/task.js +279 -0
  39. package/src/commands/verify.js +48 -0
  40. package/src/index.js +99 -0
  41. package/src/lib/adapter-login.js +76 -0
  42. package/src/lib/adapters/broker-bridge-entry.js +76 -0
  43. package/src/lib/adapters/claude.js +2207 -0
  44. package/src/lib/adapters/codex-broker-bridge.mjs +298 -0
  45. package/src/lib/adapters/codex.js +1898 -0
  46. package/src/lib/adapters/dsh.js +513 -0
  47. package/src/lib/adapters/goose.js +510 -0
  48. package/src/lib/adapters/hermes.js +18 -0
  49. package/src/lib/adapters/index.js +68 -0
  50. package/src/lib/adapters/opencode.js +1681 -0
  51. package/src/lib/adapters/pi-commonly-mcp.mjs +71 -0
  52. package/src/lib/adapters/pi-mcp-client.mjs +617 -0
  53. package/src/lib/adapters/pi-shell-env.mjs +41 -0
  54. package/src/lib/adapters/pi.js +1025 -0
  55. package/src/lib/adapters/qwen.js +523 -0
  56. package/src/lib/adapters/seat-model.js +8 -0
  57. package/src/lib/adapters/stub.js +1 -0
  58. package/src/lib/adapters/tested-version.js +106 -0
  59. package/src/lib/api.js +205 -0
  60. package/src/lib/claim-outcome.js +128 -0
  61. package/src/lib/claimed-work-gate.js +73 -0
  62. package/src/lib/cli-hint.js +33 -0
  63. package/src/lib/commit-attribution.js +391 -0
  64. package/src/lib/config.js +155 -0
  65. package/src/lib/content-refusal.js +94 -0
  66. package/src/lib/credential-file.js +208 -0
  67. package/src/lib/daemon-agent-states.js +95 -0
  68. package/src/lib/daemon-hint.js +18 -0
  69. package/src/lib/daemon-logs.js +43 -0
  70. package/src/lib/daemon-service-windows.js +263 -0
  71. package/src/lib/daemon-service.js +486 -0
  72. package/src/lib/daemon-state.js +84 -0
  73. package/src/lib/daemon-store.js +119 -0
  74. package/src/lib/daemon-supervisor.js +629 -0
  75. package/src/lib/daemon-watch.js +115 -0
  76. package/src/lib/declared-mcp-guard.js +284 -0
  77. package/src/lib/default-environment.js +207 -0
  78. package/src/lib/device-login.js +126 -0
  79. package/src/lib/digest-frame.js +151 -0
  80. package/src/lib/doctor.js +291 -0
  81. package/src/lib/egress-proxy.js +160 -0
  82. package/src/lib/enforcement.js +946 -0
  83. package/src/lib/environment.js +681 -0
  84. package/src/lib/first-output-watchdog.js +191 -0
  85. package/src/lib/git-readonly-server.js +138 -0
  86. package/src/lib/git-readonly.js +463 -0
  87. package/src/lib/git-remote-commonly.mjs +100 -0
  88. package/src/lib/grant-broker-guard.js +345 -0
  89. package/src/lib/hooks-config.js +238 -0
  90. package/src/lib/host-gh-broker.js +152 -0
  91. package/src/lib/host-gh-shim.mjs +98 -0
  92. package/src/lib/inbox.js +483 -0
  93. package/src/lib/instance-proxy.js +76 -0
  94. package/src/lib/machine-host.js +29 -0
  95. package/src/lib/mcp-credential-delivery.js +121 -0
  96. package/src/lib/mcp-first-run.js +50 -0
  97. package/src/lib/mcp-home.js +644 -0
  98. package/src/lib/mcp-ready.js +68 -0
  99. package/src/lib/mcp-server-version.js +123 -0
  100. package/src/lib/mcp-token.js +36 -0
  101. package/src/lib/mcp-warm-child.mjs +412 -0
  102. package/src/lib/memory-bridge.js +191 -0
  103. package/src/lib/memory-import.js +156 -0
  104. package/src/lib/missing-harness.js +31 -0
  105. package/src/lib/model-unavailable.js +154 -0
  106. package/src/lib/native-runtime-inventory.js +40 -0
  107. package/src/lib/outsider-grant-mcp.js +82 -0
  108. package/src/lib/own-key-launch.js +42 -0
  109. package/src/lib/pod-catch-up.js +55 -0
  110. package/src/lib/pod-focus.js +224 -0
  111. package/src/lib/pod-tail.js +68 -0
  112. package/src/lib/poll-liveness.js +103 -0
  113. package/src/lib/poll-retry.js +81 -0
  114. package/src/lib/poller.js +200 -0
  115. package/src/lib/provider-proxy.js +42 -0
  116. package/src/lib/public-seat-mcp-flag.js +36 -0
  117. package/src/lib/public-tool-error.js +60 -0
  118. package/src/lib/public-withheld-tools.js +48 -0
  119. package/src/lib/reply-receipt.js +55 -0
  120. package/src/lib/resolve-on-path.js +22 -0
  121. package/src/lib/runtime-inventory.js +56 -0
  122. package/src/lib/runtime-observation.js +37 -0
  123. package/src/lib/sandbox/bwrap.js +275 -0
  124. package/src/lib/sandbox/codex-perimeter.js +16 -0
  125. package/src/lib/sandbox/host-check.js +268 -0
  126. package/src/lib/sandbox/mode.js +49 -0
  127. package/src/lib/sandbox/opencode-loopback.mjs +68 -0
  128. package/src/lib/sandbox/seatbelt.js +684 -0
  129. package/src/lib/sandbox/turn-dir.js +56 -0
  130. package/src/lib/sandbox/windows-unsandboxed.js +183 -0
  131. package/src/lib/sandbox/workspace-git-config.js +243 -0
  132. package/src/lib/sandbox/workspace-pin.js +158 -0
  133. package/src/lib/seat-runtime-controls.js +61 -0
  134. package/src/lib/seat-stop.js +97 -0
  135. package/src/lib/server-skills.js +32 -0
  136. package/src/lib/session-recycle.js +219 -0
  137. package/src/lib/session-store.js +215 -0
  138. package/src/lib/skill-prompt.js +74 -0
  139. package/src/lib/skills-import.js +113 -0
  140. package/src/lib/spawn-credential.js +402 -0
  141. package/src/lib/spawn-retry.js +311 -0
  142. package/src/lib/stable-install.js +176 -0
  143. package/src/lib/supervised-turn.js +352 -0
  144. package/src/lib/task.js +235 -0
  145. package/src/lib/turn-failure-notice.js +177 -0
  146. package/src/lib/unconfined-seat-guard.js +94 -0
  147. package/src/lib/update-check.js +192 -0
  148. package/src/lib/upstream-refusal.js +1 -0
  149. package/src/lib/webhook-server.js +109 -0
  150. package/src/lib/windows-command.js +393 -0
  151. package/src/lib/workspace-git.js +87 -0
  152. package/src/lib/workspace-trust.js +162 -0
@@ -0,0 +1,510 @@
1
+ /** Goose 1.54.0 wrapper. Public seats expose only the Commonly MCP extension. */
2
+ import { requireSeatModel } from './seat-model.js';
3
+ import { probeVersion } from './tested-version.js';
4
+ import { spawn as childSpawn, spawnSync } from 'node:child_process';
5
+ import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
6
+ import { tmpdir } from 'node:os';
7
+ import { isAbsolute, join } from 'node:path';
8
+ import { StringDecoder } from 'node:string_decoder';
9
+ import { buildMemoryPreamble } from '../memory-bridge.js';
10
+ import { OWNER_WOKEN_MEMORY_TOOLS } from '../public-withheld-tools.js';
11
+ import { effectiveSandboxTrust, refuseWorkspaceTrust, validateEnvironmentSpec } from '../environment.js';
12
+ import { armCredentialRelease, writeCredentialFile } from '../credential-file.js';
13
+ import { MCP_READY_FILE_ENV, bridgeReadyFile, commonlyMcpReadyFile } from '../mcp-ready.js';
14
+ import { deliverSeatCredential, withholdRuntimeCredential } from '../mcp-credential-delivery.js';
15
+ import { prepareMcpSpawn, selectedMcpVersionDir } from '../mcp-home.js';
16
+ import { describeMcpCommand } from '../mcp-server-version.js';
17
+ import { resolveProviderKeyFile, startProviderProxy } from '../provider-proxy.js';
18
+ import { resolvePublicSandboxMode } from '../sandbox/mode.js';
19
+ import { detectBwrap, wrapArgvWithBwrap } from '../sandbox/bwrap.js';
20
+ import { wrapArgvWithSeatbelt } from '../sandbox/seatbelt.js';
21
+ import { refuseWorkspaceHoldingTurnDirs } from '../sandbox/turn-dir.js';
22
+ import { adapterFailure, spawnCredentials } from '../upstream-refusal.js';
23
+ import { isOurGrantBroker } from '../grant-broker-guard.js';
24
+ import { brokerBridgeEntry } from './broker-bridge-entry.js';
25
+ import { BRIDGE_PATH } from './codex-broker-bridge.mjs';
26
+
27
+ // A version enters this list only after a real headless turn with that binary.
28
+ // Each version here is proven by the real-binary smoke test on macOS and Linux
29
+ // (.github/workflows/goose-public-proof.yml carries one pinned row per version).
30
+ // 1.56.0 is NOT admitted (11-04, F113c): its state-machine agent loop offers the
31
+ // model a `load_skill` operation on every turn that the extension allowlist
32
+ // cannot remove, so a stranger could read any skill under the seat's working
33
+ // directory (.agents/.goose/.claude skills) and Goose's builtin skills. The
34
+ // proof failed on both hosts at the allowlist check. Re-admit a version only
35
+ // once skills can be switched off for public turns and the proof is green.
36
+ export const TESTED_GOOSE_VERSIONS = Object.freeze(['1.54.0']);
37
+ const DEFAULT_TIMEOUT_MS = 15 * 60 * 1000;
38
+ const TERMINATION_GRACE_MS = 5000;
39
+ const DEFAULT_PROVIDER_HOST = 'https://litellm.commonly.me';
40
+ const DEFAULT_PROVIDER_KEY = 'COMMONLY_LITELLM_KEY';
41
+ const PLACEHOLDER_RE = /\$\{(COMMONLY_[A-Z_]+)\}/g;
42
+ const SAFE_ENV = /^(?:PATH|LANG|LC_[A-Z_]+|TERM|USER|LOGNAME|NO_COLOR|CI|SSL_CERT_FILE|SSL_CERT_DIR)$/;
43
+ const safeEnv = (env) => Object.fromEntries(Object.entries(env).filter(([name]) => SAFE_ENV.test(name)));
44
+
45
+ // Goose renders a recipe file as a template before it reads it, so a stranger's
46
+ // `{{ … }}` or `{% include %}` in the prompt would run. Every `{` is written as
47
+ // an expression that prints one, which renders back to exactly the given text.
48
+ const templateLiteral = (value) => {
49
+ if (typeof value === 'string') return value.replace(/\{/g, "{{ '{' }}");
50
+ if (Array.isArray(value)) return value.map(templateLiteral);
51
+ if (value && typeof value === 'object') {
52
+ return Object.fromEntries(Object.entries(value).map(([name, item]) => [name, templateLiteral(item)]));
53
+ }
54
+ return value;
55
+ };
56
+ // Explicit API-only list: new MCP tools do not silently gain public authority.
57
+ // attach_file reads this process's filesystem, including its credential file.
58
+ // catch_up is left out too: it moves the read cursor past history unread, so a
59
+ // public seat past 50 unseen falls back to continueAnyway, which it keeps.
60
+ // Self-install, the memory writes (log_cycle included) and delete_message are
61
+ // left out on every public seat (F118, F141, PUBLIC_ONLY_WITHHELD_TOOLS);
62
+ // reading memory stays. The writes join only on a turn its owner woke (F145).
63
+ export const PUBLIC_GOOSE_TOOLS = Object.freeze([
64
+ 'commonly_get_started', 'commonly_post_message', 'commonly_claim_message',
65
+ 'commonly_release_claim', 'commonly_get_messages', 'commonly_get_context',
66
+ 'commonly_list_files', 'commonly_list_artifacts', 'commonly_read_file',
67
+ 'commonly_get_posts', 'commonly_post_thread_comment', 'commonly_get_tasks',
68
+ 'commonly_create_task', 'commonly_claim_task', 'commonly_watch_task',
69
+ 'commonly_complete_task', 'commonly_update_task', 'commonly_create_pod',
70
+ 'commonly_list_pods', 'commonly_read_agent_memory',
71
+ 'commonly_dm_agent', 'commonly_ask_agent', 'commonly_request_decision',
72
+ 'commonly_respond_to_ask', 'commonly_react_to_message',
73
+ ]);
74
+
75
+ const publicGooseTools = (ctx) => (ctx?.ownerWoken === true
76
+ ? [...PUBLIC_GOOSE_TOOLS, ...OWNER_WOKEN_MEMORY_TOOLS]
77
+ : PUBLIC_GOOSE_TOOLS);
78
+
79
+ export const assertSandboxSupported = (environment = {}) => {
80
+ const sandbox = environment?.sandbox || {};
81
+ refuseWorkspaceTrust(sandbox, 'goose');
82
+ if (effectiveSandboxTrust(sandbox.trust) === 'public') {
83
+ if (sandbox.filesystem?.['read-outside']?.length) {
84
+ throw new Error('goose adapter: public seats do not allow sandbox.filesystem.read-outside');
85
+ }
86
+ if (!['workspace', 'read-only', 'bwrap'].includes(resolvePublicSandboxMode(sandbox))) {
87
+ throw new Error('goose adapter: public seats require an enforced sandbox');
88
+ }
89
+ if (!environment.provider || !environment.model) {
90
+ throw new Error('goose adapter: public seats require an explicit provider and model');
91
+ }
92
+ if (sandbox.network?.policy === 'restricted') {
93
+ throw new Error('goose adapter: restricted networking is not supported');
94
+ }
95
+ } else if (sandbox.mode && sandbox.mode !== 'none') {
96
+ throw new Error(`goose adapter: sandbox.mode=${sandbox.mode} cannot be enforced by Goose`);
97
+ }
98
+ };
99
+
100
+ // The version is read whole or not at all. A prerelease is kept (`1.54.0-rc.1` is not the tested 1.54.0), and a
101
+ // version touching a letter, digit, `.`, `+`, `-` or `~` on either side (`1.54.0rc1`, `1.54.0.1`, `2.1.54.0`,
102
+ // `v1.54.0`, `1.54.0+build`, the Debian-style `1.54.0~rc1`) reads as unknown, so the public gate never admits part
103
+ // of a longer string.
104
+ const versionOf = (value) => String(value || '').trim()
105
+ .match(/(?<![\w.+~-])(\d+\.\d+\.\d+(?:-[0-9A-Za-z]+(?:\.[0-9A-Za-z]+)*)?)(?![\w.+~-])/)?.[1] || null;
106
+
107
+ /** Goose splits this string itself; reject quotes it cannot represent safely. */
108
+ const gooseWord = (value) => {
109
+ const word = String(value);
110
+ if (!word || /["\r\n\0]/.test(word)) throw new Error('goose adapter: MCP argument contains an unsupported character');
111
+ return `"${word}"`;
112
+ };
113
+
114
+ const substitute = (value, ctx) => String(value).replace(PLACEHOLDER_RE, (whole, name) => ({
115
+ COMMONLY_TOKEN_FILE: ctx.credentialFile,
116
+ COMMONLY_API_URL: ctx.instanceUrl,
117
+ COMMONLY_INSTANCE_URL: ctx.instanceUrl,
118
+ }[name] || whole));
119
+
120
+ /**
121
+ * F175d: the grant broker as a recipe extension running our stdio bridge, or
122
+ * null when this entry can't be wired. Goose fills an HTTP header from its own
123
+ * environment or a literal in the recipe, and neither keeps the token to the
124
+ * credential file. No `available_tools`: the broker lists only what the grant
125
+ * holds, and re-checks the grant and this seat on every call.
126
+ */
127
+ export const bridgedGooseExtension = (server, ctx) => {
128
+ const entry = brokerBridgeEntry(server, ctx);
129
+ if (entry.skip) {
130
+ // eslint-disable-next-line no-console
131
+ console.warn(`[goose] grant broker ${server.name}: ${entry.skip}, so it is not wired for this spawn.`);
132
+ return null;
133
+ }
134
+ const readyPath = bridgeReadyFile(server, ctx);
135
+ return {
136
+ type: 'stdio', name: server.name, cmd: entry.command, args: entry.args,
137
+ envs: readyPath ? { ...entry.env, [MCP_READY_FILE_ENV]: readyPath } : entry.env,
138
+ };
139
+ };
140
+
141
+ /** The flag contains paths and URLs only. Literal MCP secrets cannot ride argv. */
142
+ export const extensionArgs = (servers, ctx) => {
143
+ const args = [];
144
+ // A broker is the one extra a public seat may hold. A private seat has no
145
+ // jail, so the guard withholds its broker, and an entry that reaches here
146
+ // anyway meets the HTTP refusal below.
147
+ const declared = Array.isArray(servers) ? servers : [];
148
+ const brokers = ctx.publicSeat
149
+ ? declared.filter((server) => isOurGrantBroker(server, { instanceUrl: ctx.instanceUrl })) : [];
150
+ const others = declared.filter((server) => !brokers.includes(server));
151
+ if (ctx.publicSeat && others.length !== 1) {
152
+ throw new Error('goose adapter: public seats require only the Space MCP extension');
153
+ }
154
+ for (const server of others) {
155
+ if (ctx.publicSeat && (server.name !== 'commonly' || (server.transport || 'stdio') !== 'stdio'
156
+ || !describeMcpCommand(server.command)?.isCommonly)) {
157
+ throw new Error('goose adapter: public seats require only the Space stdio MCP extension');
158
+ }
159
+ if (!/^[A-Za-z0-9_-]{2,}$/.test(server?.name || '')) {
160
+ throw new Error('goose adapter: MCP server needs a safe name');
161
+ }
162
+ const transport = server.transport || 'stdio';
163
+ if (transport === 'http') {
164
+ if (server.headers && Object.keys(server.headers).length) {
165
+ throw new Error(`goose adapter: HTTP MCP server ${server.name} has headers Goose cannot deliver without exposing credentials`);
166
+ }
167
+ if (typeof server.url !== 'string' || !server.url) throw new Error(`goose adapter: ${server.name} has no HTTP URL`);
168
+ const url = substitute(server.url, ctx);
169
+ const parsed = new URL(url);
170
+ if (parsed.username || parsed.password || parsed.search || parsed.hash) {
171
+ throw new Error(`goose adapter: HTTP MCP server ${server.name} contains a credential-bearing URL`);
172
+ }
173
+ if (parsed.origin !== new URL(ctx.instanceUrl).origin) {
174
+ throw new Error(`goose adapter: HTTP MCP server ${server.name} is outside the Space instance`);
175
+ }
176
+ args.push('--with-streamable-http-extension', url);
177
+ continue;
178
+ }
179
+ if (transport !== 'stdio' || !Array.isArray(server.command) || !server.command.length) {
180
+ throw new Error(`goose adapter: MCP server ${server.name} has an unsupported transport or command`);
181
+ }
182
+ const command = prepareMcpSpawn(server.command, { apiUrl: ctx.instanceUrl, publicSeat: ctx.publicSeat });
183
+ if (!command) throw new Error('goose adapter: Space MCP is not prepared for this public turn');
184
+ if (ctx.publicSeat) {
185
+ const tree = selectedMcpVersionDir(command);
186
+ if (!tree || !isAbsolute(tree)) throw new Error('goose adapter: public MCP requires an absolute prepared entry');
187
+ ctx.mcpReadDirectories?.push(tree);
188
+ }
189
+ const delivery = deliverSeatCredential({ ...server, command }, {
190
+ credentialFile: ctx.credentialFile, label: 'goose',
191
+ });
192
+ if (delivery.delivered === 'env' || delivery.delivered === 'unavailable') {
193
+ throw new Error(`goose adapter: MCP server ${server.name} cannot receive its credential by file`);
194
+ }
195
+ const env = Object.entries(delivery.env).map(([name, value]) => {
196
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) throw new Error('goose adapter: invalid MCP environment key');
197
+ // COMMONLY_MCP_PUBLIC_SEAT is the constant '1' (public-seat-mcp-flag.js),
198
+ // never a secret, so it may ride either path.
199
+ if (!['COMMONLY_TOKEN_FILE', 'COMMONLY_API_URL', 'COMMONLY_INSTANCE_URL', 'COMMONLY_MCP_PUBLIC_SEAT'].includes(name)) {
200
+ throw new Error(`goose adapter: MCP server ${server.name} has ${name}, which cannot safely ride a CLI flag`);
201
+ }
202
+ const resolved = substitute(value, ctx);
203
+ if (resolved.includes('${COMMONLY_AGENT_TOKEN}') || (ctx.runtimeToken && resolved.includes(ctx.runtimeToken))) {
204
+ throw new Error(`goose adapter: MCP server ${server.name} would expose the runtime token in argv`);
205
+ }
206
+ return gooseWord(`${name}=${resolved}`);
207
+ });
208
+ const commandWords = command.map((part) => gooseWord(substitute(part, ctx)));
209
+ if (ctx.publicSeat) {
210
+ // F460: our server names the file it writes once it has read the
211
+ // credential (a path, not a secret). An older warm build refuses.
212
+ const readyPath = commonlyMcpReadyFile(server, command, ctx, { env: delivery.env });
213
+ ctx.publicExtensions.push({ type: 'stdio', name: server.name, cmd: command[0],
214
+ args: command.slice(1).map((part) => substitute(part, ctx)),
215
+ envs: {
216
+ ...Object.fromEntries(Object.entries(delivery.env).map(([name, value]) => [name, substitute(value, ctx)])),
217
+ ...(readyPath ? { [MCP_READY_FILE_ENV]: readyPath } : {}),
218
+ },
219
+ available_tools: publicGooseTools(ctx),
220
+ });
221
+ } else {
222
+ args.push('--with-extension', `${server.name}:${[...env, ...commandWords].join(' ')}`);
223
+ }
224
+ }
225
+ for (const server of brokers) {
226
+ const extension = bridgedGooseExtension(server, ctx);
227
+ if (extension) {
228
+ ctx.publicExtensions.push(extension);
229
+ ctx.brokerExtensions?.add(extension.name);
230
+ ctx.mcpReadDirectories?.push(BRIDGE_PATH);
231
+ }
232
+ }
233
+ return args;
234
+ };
235
+
236
+ export const readGooseStream = (stdout) => {
237
+ let text = '';
238
+ let error = null;
239
+ let complete = false;
240
+ for (const line of String(stdout || '').split('\n')) {
241
+ let event;
242
+ try { event = JSON.parse(line); } catch { continue; }
243
+ if (event?.type === 'complete') complete = true;
244
+ if (event?.type === 'error') error = event.error?.message || event.message || 'Goose turn failed';
245
+ if (event?.type === 'message' && event.message?.role === 'assistant'
246
+ && event.message?.metadata?.userVisible !== false) {
247
+ const body = event.message.content?.filter((item) => item?.type === 'text' && typeof item.text === 'string')
248
+ .map((item) => item.text).join('') || '';
249
+ if (body.trim()) text = body.trim();
250
+ }
251
+ }
252
+ return { text, error, complete };
253
+ };
254
+
255
+ const runGoose = ({
256
+ binary, args, cwd, env, timeoutMs, credentials, optionalExtensions = new Set(), spawnImpl = childSpawn,
257
+ }) => new Promise((resolve, reject) => {
258
+ const child = spawnImpl(binary, args, { cwd, env, stdio: ['ignore', 'pipe', 'pipe'] });
259
+ let stdout = '';
260
+ const stdoutDecoder = new StringDecoder('utf8');
261
+ let stderr = '';
262
+ let timedOut = false;
263
+ let streamOverflow = false;
264
+ let extensionFailure = null;
265
+ let settled = false;
266
+ let killTimer;
267
+ const finish = (error, result) => {
268
+ if (settled) return;
269
+ settled = true;
270
+ clearTimeout(timer);
271
+ clearTimeout(killTimer);
272
+ if (error) reject(error); else resolve(result);
273
+ };
274
+ const timer = setTimeout(() => {
275
+ timedOut = true;
276
+ child.kill('SIGTERM');
277
+ killTimer = setTimeout(() => child.kill('SIGKILL'), TERMINATION_GRACE_MS);
278
+ }, timeoutMs);
279
+ child.stdout?.on('data', (chunk) => {
280
+ if (streamOverflow) return;
281
+ stdout += stdoutDecoder.write(chunk);
282
+ if (stdout.length > 2 * 1024 * 1024) {
283
+ streamOverflow = true;
284
+ stdout = '';
285
+ clearTimeout(timer);
286
+ child.kill('SIGTERM');
287
+ killTimer = setTimeout(() => child.kill('SIGKILL'), TERMINATION_GRACE_MS);
288
+ }
289
+ });
290
+ child.stderr?.on('data', (chunk) => {
291
+ const received = chunk.toString();
292
+ // A broker that won't start (a grant refused at the door, the instance
293
+ // down) costs that grant, not the turn: the seat still answers the pod.
294
+ extensionFailure ||= [...(stderr + received).matchAll(/Failed to start extension '([^']+)'[^\n]*/g)]
295
+ .find((failure) => !optionalExtensions.has(failure[1]))?.[0] || null;
296
+ stderr = (stderr + received).slice(-8192);
297
+ });
298
+ child.on('error', (error) => finish(error));
299
+ child.on('close', (code) => {
300
+ stdout += stdoutDecoder.end();
301
+ if (timedOut) return finish(new Error(`goose adapter: timed out after ${timeoutMs}ms`));
302
+ if (streamOverflow) return finish(new Error('goose adapter: stream exceeded 2 MiB'));
303
+ const result = readGooseStream(stdout);
304
+ // Goose reports an extension startup failure on stderr yet exits 0 and
305
+ // lets the model answer without that tool. A Commonly seat cannot silently
306
+ // lose its only path back to the pod.
307
+ if (code !== 0 || result.error || !result.complete || extensionFailure) {
308
+ return finish(adapterFailure('goose', extensionFailure || result.error || stderr.trim() || `exit ${code}; incomplete stream`, {
309
+ credentials, exitCode: code, limit: 500,
310
+ }));
311
+ }
312
+ return finish(null, result.text);
313
+ });
314
+ });
315
+
316
+ export default {
317
+ requiresModel: true,
318
+ name: 'goose',
319
+ providerKeyEnv: DEFAULT_PROVIDER_KEY,
320
+ supportsProvider: true,
321
+
322
+ async validateEnvironment(environment, workspacePath) {
323
+ assertSandboxSupported(environment);
324
+ const validation = validateEnvironmentSpec(environment || {});
325
+ if (!validation.ok) throw new Error(validation.errors.join('; '));
326
+ return resolveProviderKeyFile(environment?.provider, workspacePath, 'Goose', { environment });
327
+ },
328
+
329
+ async detect() {
330
+ const result = spawnSync('goose', ['--version'], { encoding: 'utf8', timeout: 5000 });
331
+ if (result.error || result.status !== 0) return null;
332
+ const where = spawnSync('which', ['goose'], { encoding: 'utf8', timeout: 5000 });
333
+ return { path: String(where.stdout || 'goose').trim() || 'goose', version: versionOf(result.stdout) || 'unknown' };
334
+ },
335
+
336
+ async spawn(prompt, ctx = {}) {
337
+ const seatModel = requireSeatModel(ctx.environment, 'goose', true);
338
+ const keyFile = await this.validateEnvironment(ctx.environment, ctx.cwd);
339
+ const isPublic = effectiveSandboxTrust(ctx.environment?.sandbox?.trust) === 'public';
340
+ const sourceEnv = ctx.env || process.env;
341
+ const configured = ctx.environment?.provider;
342
+ const key = isPublic ? '' : configured
343
+ ? (await readFile(keyFile, 'utf8')).trim() : sourceEnv[DEFAULT_PROVIDER_KEY];
344
+ if (!isPublic && !key) throw new Error(`goose adapter: ${configured ? 'provider.keyFile' : DEFAULT_PROVIDER_KEY} is missing`);
345
+ const baseUrl = new URL(configured?.baseURL || `${DEFAULT_PROVIDER_HOST}/v1`);
346
+ if (!/^https?:$/.test(baseUrl.protocol) || baseUrl.username || baseUrl.password || baseUrl.search || baseUrl.hash) {
347
+ throw new Error('goose adapter: provider.baseURL must be a plain HTTP(S) URL');
348
+ }
349
+ const model = seatModel;
350
+ const binary = ctx._binaryPath || (isPublic
351
+ ? String(spawnSync('which', ['goose'], { encoding: 'utf8', env: sourceEnv }).stdout || '').trim()
352
+ : 'goose');
353
+ if (isPublic && !isAbsolute(binary)) throw new Error('goose adapter: public seats require an absolute Goose executable');
354
+ // A cold first launch can take several seconds while macOS scans the binary.
355
+ // Async and cached per binary (tested-version.js probeVersion): a
356
+ // synchronous probe stalled every other seat's provider proxy on the
357
+ // daemon's loop for as long as this cold start took.
358
+ const version = ctx._version || await probeVersion(binary, {
359
+ env: safeEnv(sourceEnv), timeoutMs: 30000, parse: versionOf,
360
+ });
361
+ if (!TESTED_GOOSE_VERSIONS.includes(version)) {
362
+ if (isPublic) throw new Error(`goose adapter: public seats refuse untested version ${version || 'unknown'}`);
363
+ // eslint-disable-next-line no-console
364
+ console.warn(`[goose] installed ${version || 'unknown'} differs from smoke-tested ${TESTED_GOOSE_VERSIONS.join(', ')}; private seat continues`);
365
+ }
366
+ if (isPublic) await refuseWorkspaceHoldingTurnDirs({ workspace: ctx.cwd, seat: 'public Goose agents' });
367
+ // Keep child-writable state separate from adapter-owned config and keys, as
368
+ // qwen does (F219). The jail reads tempDir and cannot change it: the
369
+ // Commonly MCP child runs in the jail, so the credential stays readable.
370
+ const tempDir = await mkdtemp(join(tmpdir(), 'commonly-goose-'));
371
+ const home = await mkdtemp(join(tmpdir(), 'commonly-goose-home-'));
372
+ let proxy;
373
+ let credentialRelease = null;
374
+ let reply = null;
375
+ let cleanupFailure = null;
376
+ try {
377
+ const credential = writeCredentialFile(ctx.runtimeToken, {
378
+ root: tempDir, agentName: ctx.agentName || 'agent',
379
+ });
380
+ // F460: the jail writes only the seat's home, so a public seat's MCP
381
+ // servers write their ready files there.
382
+ const readyFileDir = isPublic && credential ? join(home, 'mcp-ready') : null;
383
+ if (readyFileDir) await mkdir(readyFileDir, { recursive: true, mode: 0o700 });
384
+ const readyFiles = [];
385
+ // No tokenFile: nothing here reads one, so the token is never on disk.
386
+ if (isPublic) proxy = await startProviderProxy({
387
+ provider: configured, keyFile, adapterLabel: 'Goose', pinnedModel: model,
388
+ });
389
+ const mcpReadDirectories = [];
390
+ const publicExtensions = [];
391
+ const brokerExtensions = new Set();
392
+ // Node's compiled-in OpenSSL config can sit outside the permitted tree
393
+ // (including hostedtoolcache on CI). Use our empty config, retaining
394
+ // Node's bundled TLS roots, instead of granting host configuration reads.
395
+ const opensslConfig = join(tempDir, 'openssl.cnf');
396
+ if (isPublic) await writeFile(opensslConfig, '', { mode: 0o600 });
397
+ // The recipe's own directory is the template include root, so it holds
398
+ // the recipe alone, never the credential file.
399
+ const recipeDir = join(tempDir, 'recipe');
400
+ const recipePath = join(recipeDir, 'public-recipe.json');
401
+ const args = [
402
+ 'run', '--no-session', ...(!isPublic ? ['--no-profile', '--with-builtin', 'developer'] : []),
403
+ ...(isPublic ? ['--recipe', recipePath] : []),
404
+ '--output-format', 'stream-json', '--provider', 'openai', '--model', model,
405
+ ...extensionArgs(ctx.environment?.mcp, {
406
+ credentialFile: credential?.path || '', instanceUrl: ctx.instanceUrl || '',
407
+ runtimeToken: ctx.runtimeToken || '',
408
+ publicSeat: isPublic, ownerWoken: ctx.ownerWoken, mcpReadDirectories, publicExtensions, brokerExtensions,
409
+ readyFileDir, readyFiles,
410
+ }),
411
+ ...(!isPublic ? ['-t', buildMemoryPreamble(prompt, ctx.memoryLongTerm, { freshSession: true })] : []),
412
+ ];
413
+ if (isPublic) await mkdir(recipeDir, { mode: 0o700 });
414
+ if (isPublic) await writeFile(recipePath, JSON.stringify(templateLiteral({ version: '1.0.0',
415
+ title: 'Space public seat', description: 'Only audited Space API tools are available.',
416
+ instructions: 'Respond to the user using the available Space tools.',
417
+ extensions: publicExtensions.map((extension) => ({ ...extension,
418
+ envs: { ...extension.envs, OPENSSL_CONF: opensslConfig },
419
+ })),
420
+ // F118, F145: a public seat reads its memory, and is offered a write
421
+ // only on a turn its owner woke.
422
+ prompt: buildMemoryPreamble(prompt, ctx.memoryLongTerm, { freshSession: true, memoryReadOnly: ctx.ownerWoken !== true }),
423
+ })), { mode: 0o600 });
424
+ const env = {
425
+ ...safeEnv(sourceEnv),
426
+ HOME: home,
427
+ TMPDIR: home,
428
+ XDG_CONFIG_HOME: join(home, 'config'),
429
+ XDG_DATA_HOME: join(home, 'data'),
430
+ GOOSE_PATH_ROOT: home,
431
+ GOOSE_PROVIDER: 'openai',
432
+ GOOSE_MODEL: model,
433
+ GOOSE_MODE: 'auto',
434
+ GOOSE_TELEMETRY_ENABLED: 'false',
435
+ ...(isPublic ? { OPENSSL_CONF: opensslConfig } : {}),
436
+ OPENAI_HOST: proxy ? new URL(proxy.baseURL).origin : baseUrl.origin,
437
+ OPENAI_BASE_PATH: `${new URL(proxy?.baseURL || baseUrl).pathname.replace(/\/$/, '')}/chat/completions`.replace(/^\//, ''),
438
+ OPENAI_API_KEY: proxy ? proxy.token : key,
439
+ };
440
+ withholdRuntimeCredential(env, { credentialFile: credential?.path || null });
441
+ const requestedTimeout = Number(ctx.timeoutMs);
442
+ const timeoutMs = Number.isFinite(requestedTimeout) && requestedTimeout > 0
443
+ ? Math.min(requestedTimeout, DEFAULT_TIMEOUT_MS) : DEFAULT_TIMEOUT_MS;
444
+ let executable = binary;
445
+ let spawnArgs = args;
446
+ if (isPublic) {
447
+ const sandbox = { ...ctx.environment.sandbox, trust: 'public' };
448
+ const mode = resolvePublicSandboxMode(sandbox, ctx._platform || process.platform);
449
+ if (mode === 'bwrap') {
450
+ const available = (ctx._detectBwrap || detectBwrap)();
451
+ if (!available.available) throw new Error(`goose adapter: bwrap unavailable: ${available.error}`);
452
+ [executable, ...spawnArgs] = (ctx._wrapArgvWithBwrap || wrapArgvWithBwrap)([binary, ...args], {
453
+ ...ctx.environment, sandbox: { ...sandbox, filesystem: {
454
+ ...(sandbox.filesystem || {}), 'write-outside': [home],
455
+ } },
456
+ }, { workspacePath: ctx.cwd, homePath: home,
457
+ readOnlyPaths: [tempDir, binary, process.execPath, ...mcpReadDirectories] });
458
+ } else {
459
+ if ((ctx._platform || process.platform) !== 'darwin') throw new Error('goose adapter: Seatbelt requires macOS');
460
+ [executable, ...spawnArgs] = (ctx._wrapArgvWithSeatbelt || wrapArgvWithSeatbelt)([binary, ...args], {
461
+ workspacePath: ctx.cwd, workspaceAccess: mode === 'read-only' ? 'read' : 'write',
462
+ executablePath: binary, statePath: home, mcpConfigDir: tempDir,
463
+ // Public seat: the CLI made this workspace, so it is pinned (F197).
464
+ pinWorkspace: true,
465
+ loopbackNetworkPorts: [proxy.port], readOnlyDirectories: mcpReadDirectories,
466
+ });
467
+ }
468
+ }
469
+ // F460: on a public seat the credential goes once every MCP reader has it
470
+ // (their ready files), or at the deadline. A delete that fails ends the turn.
471
+ let releaseFailure = null;
472
+ let child = null;
473
+ const spawnImpl = (...spawnArgs) => {
474
+ child = (ctx._spawnImpl || childSpawn)(...spawnArgs);
475
+ return child;
476
+ };
477
+ if (readyFileDir) {
478
+ credentialRelease = armCredentialRelease(credential, {
479
+ readyFiles: readyFiles.map(({ path }) => path),
480
+ label: 'public Goose seat',
481
+ // eslint-disable-next-line no-console
482
+ warn: (text) => console.warn(`[goose] ${text}`),
483
+ onFailure: (err) => { releaseFailure = err; child?.kill?.('SIGKILL'); },
484
+ ...ctx._credentialRelease,
485
+ });
486
+ if (credentialRelease.error) throw credentialRelease.error;
487
+ }
488
+ const text = await runGoose({
489
+ binary: executable, args: spawnArgs, cwd: ctx.cwd, env, timeoutMs,
490
+ credentials: [...spawnCredentials(ctx, [DEFAULT_PROVIDER_KEY]), key, env.OPENAI_API_KEY],
491
+ optionalExtensions: brokerExtensions,
492
+ spawnImpl,
493
+ }).catch((err) => { throw releaseFailure || err; });
494
+ if (releaseFailure) throw releaseFailure;
495
+ reply = { text, newSessionId: null };
496
+ } finally {
497
+ credentialRelease?.stop();
498
+ try { await proxy?.close(); } finally {
499
+ const removed = await Promise.allSettled([tempDir, home].map((path) => rm(path, { recursive: true, force: true })));
500
+ cleanupFailure = removed.find((outcome) => outcome.status === 'rejected')?.reason || null;
501
+ }
502
+ }
503
+ // Reached only when the turn worked. A cleanup failure is then the turn's
504
+ // error: on a private seat that rm is the one delete of the credential, so
505
+ // the turn must not report success over it. When another error ended the
506
+ // turn (a failed release, say), it propagates past this and is kept.
507
+ if (cleanupFailure) throw cleanupFailure;
508
+ return reply;
509
+ },
510
+ };
@@ -0,0 +1,18 @@
1
+ import { homedir } from 'os';
2
+ import { join } from 'path';
3
+ import { createHermesAdapter } from '@commonlyai/seats/adapters/hermes';
4
+ import { buildMemoryPreamble } from '../memory-bridge.js';
5
+ import { effectiveSandboxTrust } from '../environment.js';
6
+ import { seatModelRequired } from './seat-model.js';
7
+
8
+ export { SUPPORTED_HERMES_VERSION_RANGE, readHermesJsonl, syncProfileEnv } from '@commonlyai/seats/adapters/hermes';
9
+
10
+ // Commonly's policy for the generic Hermes adapter: its memory preamble, its
11
+ // legacy sandbox-trust names, the fleet seat-model switch, and the profile
12
+ // directory seats have always used.
13
+ export default createHermesAdapter({
14
+ memoryPreamble: buildMemoryPreamble,
15
+ effectiveTrust: effectiveSandboxTrust,
16
+ seatModelRequired,
17
+ profileRoot: join(homedir(), '.commonly', 'hermes', 'profiles'),
18
+ });
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Adapter registry — ADR-005 §Adapter pattern.
3
+ *
4
+ * `attach <adapter>` and `run <name>` both resolve an adapter by its string
5
+ * name through this single registry. Adding a new CLI (claude, codex, cursor,
6
+ * gemini, …) is a one-file PR that exports a default adapter and adds an
7
+ * entry below.
8
+ */
9
+
10
+ import stub from './stub.js';
11
+ import claude from './claude.js';
12
+ import codex from './codex.js';
13
+ import pi from './pi.js';
14
+ import opencode from './opencode.js';
15
+ import qwen from './qwen.js';
16
+ import dsh from './dsh.js';
17
+ import goose from './goose.js';
18
+ import hermes from './hermes.js';
19
+
20
+ const ADAPTERS = {
21
+ [stub.name]: stub,
22
+ [claude.name]: claude,
23
+ [codex.name]: codex,
24
+ [pi.name]: pi,
25
+ [opencode.name]: opencode,
26
+ [qwen.name]: qwen,
27
+ [dsh.name]: dsh,
28
+ [goose.name]: goose,
29
+ [hermes.name]: hermes,
30
+ };
31
+
32
+ export const listAdapterNames = () => Object.keys(ADAPTERS);
33
+
34
+ // The adapters a person can choose: every one but the internal test stub. Help
35
+ // text and "Known:" lists read this, so they never drift from the registry.
36
+ export const listPublicAdapterNames = () => listAdapterNames().filter((name) => name !== stub.name);
37
+
38
+ export const getAdapter = (name) => ADAPTERS[name] || null;
39
+
40
+ /**
41
+ * TASK-049: the environment a seat's ADAPTER needs from the operator's shell.
42
+ *
43
+ * The adapter owns the provider, so the adapter declares the variable (see
44
+ * `pi.js`); the daemon reads the list to carry those keys into its login
45
+ * service, because launchd and systemd start the daemon with a clean
46
+ * environment — a key that exists only in an interactive shell is absent at
47
+ * boot, and the seat then dies inside its own adapter. An adapter that
48
+ * authenticates through its own CLI (claude, codex) declares nothing here and
49
+ * contributes nothing.
50
+ */
51
+ export const providerKeyEnvNames = (adapters = ADAPTERS) => [...new Set(
52
+ Object.values(adapters).map((adapter) => adapter.providerKeyEnv).filter(Boolean),
53
+ )];
54
+
55
+ /**
56
+ * The provider key ONE seat reads from the environment, or null when it reads
57
+ * none: its adapter signs in through its own CLI (claude, codex), or its
58
+ * provider block names a keyFile. The daemon warns per seat with this, so a
59
+ * BYO computer running only claude never hears about keys it has no use for
60
+ * (Growth's F370 walk).
61
+ */
62
+ export const seatProviderKeyEnv = ({ adapter, environment, adapters = ADAPTERS } = {}) => {
63
+ const declared = adapters[adapter]?.providerKeyEnv;
64
+ if (!declared) return null;
65
+ const provider = environment?.provider;
66
+ if (provider?.keyFile) return null;
67
+ return provider?.apiKeyEnv || declared;
68
+ };