@ravefox/space 0.0.0-stage → 0.2.1

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 +205 -0
  2. package/NOTICE +55 -0
  3. package/README.md +63 -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 +63 -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,681 @@
1
+ /**
2
+ * Agent Environment resolver — ADR-008 Phase 1.
3
+ *
4
+ * An environment.yaml/.json file is the user-authored, driver-neutral spec for
5
+ * how an agent's runtime should be shaped: workspace path, sandbox mode, the
6
+ * Claude skills to mount, the MCP servers to expose. This module is the
7
+ * adapter-facing read-side: parse, validate, and realize the workspace +
8
+ * skill mounts on disk. Sandbox argv wrapping lives in `./sandbox/bwrap.js`
9
+ * because it is per-driver (Linux-only, bwrap-specific).
10
+ *
11
+ * Zero runtime deps: Node 20 has no built-in YAML, so we accept JSON only and
12
+ * surface a clear error on .yaml/.yml inputs (per task brief). When a future
13
+ * Node version exposes `node:yaml` natively, parseEnvironmentFile is the one
14
+ * place to add a `.yaml` branch — the rest of the module is parser-agnostic.
15
+ */
16
+
17
+ import {
18
+ readFile, mkdir, lstat, cp, rm, chmod, readdir, writeFile,
19
+ } from 'fs/promises';
20
+ import { existsSync } from 'fs';
21
+ import { isAbsolute, join, resolve as pathResolve, basename } from 'path';
22
+ import { homedir } from 'os';
23
+ import { resolveSkillSource } from './skill-prompt.js';
24
+
25
+ // ── Schema — keep the allow-list narrow; ADR-008 §invariants #1+#2 ──────────
26
+ //
27
+ // Deliberate omission: the env file's directory (needed to resolve `seed` and
28
+ // relative skill paths) is NOT stored on the returned spec. Leaking the user's
29
+ // absolute filesystem path into `config.environment` sent to the backend
30
+ // exposes `$HOME` layout for zero server-side benefit. Callers pass
31
+ // `envFileDir` as a separate argument to resolveWorkspace / mountSkills.
32
+
33
+ // `model` closes issue #774. Before it, the only way to pin a wrapper's model
34
+ // was `ANTHROPIC_MODEL` exported at `commonly agent run` time — per-process,
35
+ // never persisted, and silently dropped by any restart. Measured consequence on
36
+ // 2026-08-16: six of nine live seats had lost their assigned model, including
37
+ // ux-lead, which was supposed to be on Fable and had been running the CLI
38
+ // default for days with nothing recording the drift.
39
+ //
40
+ // Putting it in the env spec makes the model a persisted, server-side fact that
41
+ // survives restarts and is readable by the platform — which is what ADR-022's
42
+ // "persona and runtime are chosen separately" requires to mean anything for a
43
+ // BYO seat, and what lets an identity card answer "what is this running".
44
+ const ALLOWED_TOP_KEYS = new Set([
45
+ 'version', 'workspace', 'sandbox', 'skills', 'mcp', 'model', 'effort', 'provider',
46
+ ]);
47
+ const ALLOWED_SANDBOX_MODES = new Set([
48
+ 'none', 'workspace', 'read-only', 'bwrap', 'firejail', 'container', 'managed',
49
+ ]);
50
+ // `workspace` is the middle trust the comment below waits for (TASK-294): a seat
51
+ // on a pod nobody outside it can read, allowed to read its start directory and
52
+ // run read-only git there. Only the claude adapter reads it; every other adapter
53
+ // refuses it before spawning, so it never degrades into a bare spawn.
54
+ const ALLOWED_SANDBOX_TRUST = new Set(['public', 'workspace']);
55
+ // The opt-ins a `workspace` seat can carry. Each one widens a single thing:
56
+ // `write` adds Edit/Write/Bash and a writable workspace; `web` adds WebFetch.
57
+ export const WORKSPACE_SANDBOX_ALLOW = Object.freeze(['write', 'web']);
58
+ const ALLOWED_NETWORK_POLICIES = new Set(['unrestricted', 'restricted']);
59
+ const INLINE_PROVIDER_CREDENTIAL_FIELD = /(?:key|token|secret|password|authorization|header|credential|auth)/i;
60
+
61
+ const findInlineProviderCredentialField = (value, path) => {
62
+ if (!value || typeof value !== 'object') return null;
63
+ for (const [key, nested] of Object.entries(value)) {
64
+ const normalized = key.replace(/[-_]/g, '').toLowerCase();
65
+ const fieldPath = `${path}.${key}`;
66
+ if (INLINE_PROVIDER_CREDENTIAL_FIELD.test(normalized)) return fieldPath;
67
+ const nestedPath = findInlineProviderCredentialField(nested, fieldPath);
68
+ if (nestedPath) return nestedPath;
69
+ }
70
+ return null;
71
+ };
72
+
73
+ // `trust: 'internal'` was accepted by this schema and read by NO adapter. On the
74
+ // attach path it could only name a mode the platform cannot resolve; on the
75
+ // daemon path it was silently inert — claude fell through to a bare unconfined
76
+ // spawn and codex took `--dangerously-bypass-approvals-and-sandbox` — so a
77
+ // declaration that reads as "confine me, not as a public agent" meant the
78
+ // opposite of what it said. It is refused for new declarations
79
+ // above, and a record that already carries it is resolved TOWARD confinement,
80
+ // never toward the bare spawn: `internal` is read as `public`, so
81
+ // such a seat is confined where it can be and refuses to derive where it
82
+ // cannot. A real middle trust arrives as its own adapter that reads it — and
83
+ // `workspace` (above) is that trust, which is why it is NOT a legacy alias.
84
+ export const LEGACY_SANDBOX_TRUST = Object.freeze({ internal: 'public' });
85
+ export const isLegacySandboxTrust = (sandbox) => (
86
+ typeof sandbox?.trust === 'string'
87
+ && Object.prototype.hasOwnProperty.call(LEGACY_SANDBOX_TRUST, sandbox.trust)
88
+ );
89
+ export const normalizeSandboxTrust = (sandbox) => (
90
+ isLegacySandboxTrust(sandbox)
91
+ ? { ...sandbox, trust: LEGACY_SANDBOX_TRUST[sandbox.trust] }
92
+ : sandbox
93
+ );
94
+ /**
95
+ * A declared trust resolved THROUGH the legacy table: `internal` → `public`,
96
+ * anything else (including absent) is itself.
97
+ *
98
+ * Exported so every gate reads the mapping once. `normalizeSandboxTrust` above
99
+ * rewrites a whole sandbox object; this is the scalar form for a site that only
100
+ * compares the trust and must not write anything back. The server spells the
101
+ * same predicate `effectiveSandboxTrust` (`grantBrokerConfinement.ts`) — the two
102
+ * sides have to read `internal` the same way or one of them confines a seat the
103
+ * other withholds from.
104
+ */
105
+ export const effectiveSandboxTrust = (trust) => (
106
+ typeof trust === 'string' && Object.prototype.hasOwnProperty.call(LEGACY_SANDBOX_TRUST, trust)
107
+ ? LEGACY_SANDBOX_TRUST[trust]
108
+ : trust
109
+ );
110
+
111
+ /**
112
+ * Throw when an adapter that does not implement the `workspace` tier is handed
113
+ * one (TASK-294). Only claude reads it. Every other adapter branches on
114
+ * `trust === 'public'`, so an unread `workspace` would fall into its
115
+ * non-public branch — the same silent widening `internal` was.
116
+ * Called at each adapter's spawn entry, before any other sandbox read.
117
+ */
118
+ export const refuseWorkspaceTrust = (sandbox, adapterLabel) => {
119
+ if (sandbox?.trust !== 'workspace') return;
120
+ throw new Error(
121
+ `${adapterLabel} adapter: sandbox.trust=workspace is claude-only for now. `
122
+ + "Declare sandbox.trust 'public' for this seat, or attach it with the claude adapter.",
123
+ );
124
+ };
125
+
126
+ const expandHome = (p) => {
127
+ if (!p || typeof p !== 'string') return p;
128
+ if (p === '~') return homedir();
129
+ if (p.startsWith('~/')) return join(homedir(), p.slice(2));
130
+ return p;
131
+ };
132
+
133
+ // ── parseEnvironmentFile ────────────────────────────────────────────────────
134
+
135
+ /**
136
+ * Read and parse the env file. Accepts JSON only in Phase 1 — .yaml/.yml
137
+ * inputs fail with a specific error pointing the user at JSON, so we never
138
+ * silently misread a YAML file as JSON.
139
+ */
140
+ export const parseEnvironmentFile = async (absolutePath) => {
141
+ if (!isAbsolute(absolutePath)) {
142
+ throw new Error(`parseEnvironmentFile requires an absolute path, got: ${absolutePath}`);
143
+ }
144
+ if (!existsSync(absolutePath)) {
145
+ throw new Error(`Environment file not found: ${absolutePath}`);
146
+ }
147
+
148
+ const lower = absolutePath.toLowerCase();
149
+ if (lower.endsWith('.yaml') || lower.endsWith('.yml')) {
150
+ throw new Error(
151
+ `YAML environment files are not supported in Phase 1 — Node 20 has no `
152
+ + `built-in YAML parser and we keep zero runtime deps. Convert ${absolutePath} `
153
+ + `to JSON (same shape) and pass that instead.`,
154
+ );
155
+ }
156
+
157
+ const raw = await readFile(absolutePath, 'utf8');
158
+ let parsed;
159
+ try {
160
+ parsed = JSON.parse(raw);
161
+ } catch (err) {
162
+ throw new Error(`Failed to parse environment file ${absolutePath}: ${err.message}`);
163
+ }
164
+
165
+ const validation = validateEnvironmentSpec(parsed);
166
+ if (!validation.ok) {
167
+ throw new Error(
168
+ `Invalid environment spec in ${absolutePath}:\n - ${validation.errors.join('\n - ')}`,
169
+ );
170
+ }
171
+
172
+ // Return the bare spec — no envFileDir wrapping, no underscore-prefixed
173
+ // annotations. The caller is responsible for tracking envFileDir separately
174
+ // (compute via `dirname(envPath)`) and passing it explicitly to
175
+ // resolveWorkspace / mountSkills when relative paths in the spec need to
176
+ // resolve. The provider block is host-local; API writers omit it entirely.
177
+ return parsed;
178
+ };
179
+
180
+ /**
181
+ * Remove the host-local provider declaration before an environment spec is
182
+ * sent to the Commonly API. The full spec remains in the local token record so
183
+ * the daemon can bind it to this host and the adapter can resolve its key file.
184
+ */
185
+ export const environmentForServer = (environment) => {
186
+ if (!environment || typeof environment !== 'object' || Array.isArray(environment)
187
+ || !Object.prototype.hasOwnProperty.call(environment, 'provider')) {
188
+ return environment;
189
+ }
190
+ const { provider: _hostLocalProvider, ...serverEnvironment } = environment;
191
+ return serverEnvironment;
192
+ };
193
+
194
+ // ── validateEnvironmentSpec ─────────────────────────────────────────────────
195
+
196
+ export const PROVIDER_APIS = Object.freeze(['openai-chat', 'anthropic-messages']);
197
+
198
+ // One provider check for config, attach, run and the daemon's adapter swap: the adapter must take a
199
+ // provider block at all, and speak its `provider.api` (default openai-chat). An adapter's
200
+ // `providerVerdict(environment)` answers first when it returns a refusal or null (accepted);
201
+ // undefined leaves the seat to this check.
202
+ export const providerRefusal = (environment, adapter) => {
203
+ if (environment?.provider === undefined) return null;
204
+ if (!adapter?.supportsProvider) return 'environment.provider requires a provider-capable adapter';
205
+ const verdict = adapter.providerVerdict?.(environment);
206
+ if (verdict !== undefined) return verdict;
207
+ const api = environment.provider?.api || 'openai-chat';
208
+ const apis = adapter.providerApis || ['openai-chat'];
209
+ return apis.includes(api) ? null
210
+ : `the ${adapter.name} adapter cannot use provider.api "${api}"; it supports ${apis.join(', ')}`;
211
+ };
212
+
213
+ export const validateEnvironmentSpec = (spec) => {
214
+ const errors = [];
215
+
216
+ if (!spec || typeof spec !== 'object' || Array.isArray(spec)) {
217
+ return { ok: false, errors: ['env spec must be a JSON object'] };
218
+ }
219
+
220
+ for (const key of Object.keys(spec)) {
221
+ if (!ALLOWED_TOP_KEYS.has(key)) {
222
+ errors.push(`unknown top-level key: "${key}" (allowed: ${[...ALLOWED_TOP_KEYS].join(', ')})`);
223
+ }
224
+ }
225
+
226
+ if (spec.version !== undefined && spec.version !== 1) {
227
+ errors.push(`version must be 1, got ${JSON.stringify(spec.version)}`);
228
+ }
229
+
230
+ // Validated as an opaque non-empty string, deliberately not against a list of
231
+ // known model ids. A whitelist here would need editing every time a provider
232
+ // ships a model, and would reject a valid id the local CLI understands and
233
+ // this file does not — failing the user's attach for a fact it is not the
234
+ // authority on. The adapter passes it through to `--model`; the CLI is the
235
+ // thing that knows what is valid, and its error is the honest one.
236
+ if (spec.model !== undefined) {
237
+ if (typeof spec.model !== 'string' || spec.model.trim() === '') {
238
+ errors.push('model must be a non-empty string');
239
+ }
240
+ }
241
+
242
+ if (spec.provider !== undefined) {
243
+ if (!spec.provider || typeof spec.provider !== 'object' || Array.isArray(spec.provider)) {
244
+ errors.push('provider must be an object');
245
+ } else {
246
+ const allowedProviderKeys = new Set(['id', 'baseURL', 'keyFile', 'models', 'api']);
247
+ for (const key of Object.keys(spec.provider)) {
248
+ if (!allowedProviderKeys.has(key)) {
249
+ errors.push(`unknown provider key: ${JSON.stringify(key)} (allowed: id, baseURL, keyFile, models, api)`);
250
+ }
251
+ }
252
+ const { id, baseURL, keyFile, models, api } = spec.provider;
253
+ // The wire the provider speaks. Absent means openai-chat; whether the
254
+ // chosen adapter can speak it is the adapter's `providerApis`.
255
+ if (api !== undefined && !PROVIDER_APIS.includes(api)) {
256
+ errors.push(`provider.api must be one of ${PROVIDER_APIS.join(', ')}`);
257
+ }
258
+ if (typeof id !== 'string' || !/^[A-Za-z0-9][A-Za-z0-9_-]*$/.test(id)) {
259
+ errors.push('provider.id must be a safe OpenCode provider id');
260
+ }
261
+ if (typeof baseURL !== 'string' || baseURL.trim() === '') {
262
+ errors.push('provider.baseURL must be an HTTP(S) URL');
263
+ } else {
264
+ try {
265
+ const url = new URL(baseURL);
266
+ if (!['http:', 'https:'].includes(url.protocol)
267
+ || !url.hostname
268
+ || url.username || url.password || url.search || url.hash) {
269
+ errors.push('provider.baseURL must be an HTTP(S) URL without credentials, query, or fragment');
270
+ }
271
+ } catch {
272
+ errors.push('provider.baseURL must be an HTTP(S) URL');
273
+ }
274
+ }
275
+ if (typeof keyFile !== 'string' || !isAbsolute(keyFile)) {
276
+ errors.push('provider.keyFile must be an absolute path');
277
+ } else if (/[\r\n}]/.test(keyFile)) {
278
+ errors.push('provider.keyFile contains characters that cannot be used in an OpenCode file reference');
279
+ }
280
+ if (models !== undefined) {
281
+ if (!models || typeof models !== 'object' || Array.isArray(models)) {
282
+ errors.push('provider.models must be an object keyed by bare model id');
283
+ } else {
284
+ for (const [modelId, modelConfig] of Object.entries(models)) {
285
+ if (!/^[A-Za-z0-9][A-Za-z0-9._:-]*$/.test(modelId)) {
286
+ errors.push(`provider.models key ${JSON.stringify(modelId)} must be a bare model id`);
287
+ }
288
+ if (!modelConfig || typeof modelConfig !== 'object' || Array.isArray(modelConfig)) {
289
+ errors.push(`provider.models[${JSON.stringify(modelId)}] must be an object`);
290
+ } else {
291
+ const credentialField = findInlineProviderCredentialField(
292
+ modelConfig,
293
+ `provider.models.${modelId}`,
294
+ );
295
+ if (credentialField) {
296
+ errors.push(`${credentialField} must not contain inline credentials; use provider.keyFile`);
297
+ }
298
+ }
299
+ }
300
+ }
301
+ }
302
+ if (spec.model === undefined) {
303
+ errors.push('model is required when provider is configured');
304
+ } else if (typeof spec.model === 'string'
305
+ && !/^[A-Za-z0-9][A-Za-z0-9._:-]*$/.test(spec.model)) {
306
+ errors.push('model must be a bare model id when provider is configured');
307
+ }
308
+ }
309
+ }
310
+
311
+ // `effort` is the reasoning budget the claude adapter passes to `--effort`.
312
+ // Unlike `model` it IS a closed set — the CLI documents exactly these — so a
313
+ // typo fails here at attach time, not silently at the first spawn.
314
+ if (spec.effort !== undefined) {
315
+ const EFFORTS = ['low', 'medium', 'high', 'xhigh', 'max'];
316
+ if (typeof spec.effort !== 'string' || !EFFORTS.includes(spec.effort)) {
317
+ errors.push(`effort must be one of: ${EFFORTS.join(', ')}`);
318
+ }
319
+ }
320
+
321
+ if (spec.workspace !== undefined) {
322
+ if (typeof spec.workspace !== 'object' || spec.workspace === null) {
323
+ errors.push('workspace must be an object');
324
+ } else {
325
+ if (spec.workspace.path !== undefined && typeof spec.workspace.path !== 'string') {
326
+ errors.push('workspace.path must be a string');
327
+ }
328
+ if (spec.workspace.seed !== undefined) {
329
+ if (!Array.isArray(spec.workspace.seed)
330
+ || !spec.workspace.seed.every((s) => typeof s === 'string')) {
331
+ errors.push('workspace.seed must be an array of strings');
332
+ }
333
+ }
334
+ }
335
+ }
336
+
337
+ if (spec.sandbox !== undefined) {
338
+ if (typeof spec.sandbox !== 'object' || spec.sandbox === null) {
339
+ errors.push('sandbox must be an object');
340
+ } else {
341
+ const {
342
+ mode, trust, network, filesystem, allow,
343
+ } = spec.sandbox;
344
+ if (mode !== undefined && !ALLOWED_SANDBOX_MODES.has(mode)) {
345
+ errors.push(`sandbox.mode must be one of: ${[...ALLOWED_SANDBOX_MODES].join(', ')}`);
346
+ }
347
+ if (trust !== undefined && !ALLOWED_SANDBOX_TRUST.has(trust)) {
348
+ errors.push(
349
+ "sandbox.trust must be 'public' or 'workspace' — 'public' marks a seat "
350
+ + "anyone in the pod can talk to, 'workspace' a seat on a private pod that "
351
+ + 'reads its start directory; omit it for an unconfined seat of your own '
352
+ + `(got ${JSON.stringify(trust)})`,
353
+ );
354
+ }
355
+ if (allow !== undefined) {
356
+ if (!Array.isArray(allow) || !allow.every((a) => WORKSPACE_SANDBOX_ALLOW.includes(a))) {
357
+ errors.push(`sandbox.allow must be an array of: ${WORKSPACE_SANDBOX_ALLOW.join(', ')}`);
358
+ } else if (trust !== 'workspace') {
359
+ // An opt-in widens the workspace tier and nothing else. On a public
360
+ // seat it would read as a way to hand strangers Bash.
361
+ errors.push(
362
+ "sandbox.allow applies only to trust 'workspace' "
363
+ + `(got trust ${JSON.stringify(trust)})`,
364
+ );
365
+ } else if (allow.includes('write') && mode === 'read-only') {
366
+ errors.push("sandbox.allow 'write' cannot be combined with sandbox.mode 'read-only'");
367
+ }
368
+ }
369
+ if (network !== undefined) {
370
+ if (typeof network !== 'object' || network === null) {
371
+ errors.push('sandbox.network must be an object');
372
+ } else {
373
+ if (network.policy !== undefined && !ALLOWED_NETWORK_POLICIES.has(network.policy)) {
374
+ errors.push(`sandbox.network.policy must be one of: ${[...ALLOWED_NETWORK_POLICIES].join(', ')}`);
375
+ }
376
+ if (network['allow-hosts'] !== undefined
377
+ && (!Array.isArray(network['allow-hosts'])
378
+ || !network['allow-hosts'].every((h) => typeof h === 'string'))) {
379
+ errors.push('sandbox.network.allow-hosts must be an array of strings');
380
+ }
381
+ }
382
+ }
383
+ if (filesystem !== undefined) {
384
+ if (typeof filesystem !== 'object' || filesystem === null) {
385
+ errors.push('sandbox.filesystem must be an object');
386
+ } else {
387
+ for (const f of ['read-outside', 'write-outside']) {
388
+ if (filesystem[f] !== undefined
389
+ && (!Array.isArray(filesystem[f])
390
+ || !filesystem[f].every((p) => typeof p === 'string'))) {
391
+ errors.push(`sandbox.filesystem.${f} must be an array of strings`);
392
+ }
393
+ }
394
+ }
395
+ }
396
+ }
397
+ }
398
+
399
+ if (spec.skills !== undefined) {
400
+ if (typeof spec.skills !== 'object' || spec.skills === null) {
401
+ errors.push('skills must be an object');
402
+ } else {
403
+ if (spec.skills.claude !== undefined
404
+ && (!Array.isArray(spec.skills.claude)
405
+ || !spec.skills.claude.every((p) => typeof p === 'string'))) {
406
+ errors.push('skills.claude must be an array of strings');
407
+ }
408
+ if (spec.skills.commonly !== undefined
409
+ && (!Array.isArray(spec.skills.commonly)
410
+ || !spec.skills.commonly.every((p) => typeof p === 'string'))) {
411
+ errors.push('skills.commonly must be an array of strings');
412
+ }
413
+ }
414
+ }
415
+
416
+ // A TRANSPORT DECIDES THE ENTRY, so the fields must agree with it (TASK-071).
417
+ // This block is MIRRORED, not shared: the other
418
+ // writer-side check is the backend's, in
419
+ // backend/utils/environmentSpecValidation.ts, called from the
420
+ // `PATCH /api/registry/pods/:podId/agents/:name` handler that stores
421
+ // `config.environment` for the owner's daemon. The two cannot share code at
422
+ // runtime — this is a published ESM package, that is the CJS backend — so
423
+ // the wording here and there is kept parallel deliberately and the two must
424
+ // move together. This one refuses a hand-written `--environment <file>`; the
425
+ // backend's refuses a stored row.
426
+ //
427
+ // The shape rule is not decoration: every adapter branches on it
428
+ // (isStdioServer/isHttpServer in adapters/pi-mcp-client.mjs), and `command`
429
+ // is an argv ARRAY — `connectStdioMcp` destructures `const [cmd, ...args] =
430
+ // command`. An entry whose fields contradict its transport is a record whose
431
+ // reader has to pick a winner, and the readers do not agree on which.
432
+ if (spec.mcp !== undefined) {
433
+ if (!Array.isArray(spec.mcp)) {
434
+ errors.push('mcp must be an array');
435
+ } else {
436
+ spec.mcp.forEach((server, i) => {
437
+ if (!server || typeof server !== 'object' || Array.isArray(server)) {
438
+ errors.push(`mcp[${i}] must be an object`);
439
+ return;
440
+ }
441
+ if (typeof server.name !== 'string' || !server.name) {
442
+ errors.push(`mcp[${i}].name is required and must be a non-empty string`);
443
+ }
444
+ if (server.transport !== undefined
445
+ && !['http', 'stdio', 'sse'].includes(server.transport)) {
446
+ errors.push(`mcp[${i}].transport must be one of: http, stdio, sse`);
447
+ // The agreement rule below is defined in terms of a transport this
448
+ // entry does not have; judging it here would mean inventing the
449
+ // second definition that rule exists to avoid.
450
+ return;
451
+ }
452
+ const argv = Array.isArray(server.command) && server.command.length > 0
453
+ && server.command.every((part) => typeof part === 'string' && part.trim().length > 0);
454
+ const hasUrl = typeof server.url === 'string' && server.url.trim().length > 0;
455
+ if (server.transport === 'http' || server.transport === 'sse') {
456
+ if (!hasUrl) {
457
+ errors.push(`mcp[${i}].url is required when transport is ${server.transport}: a ${server.transport} server is reached by URL, and this entry declares no URL to reach`);
458
+ }
459
+ if (server.command !== undefined) {
460
+ errors.push(`mcp[${i}].command must not be set when transport is ${server.transport}: with a url and a command in one entry each reader picks a different winner, so the record does not say what runs`);
461
+ }
462
+ } else {
463
+ // stdio, or absent transport — the historical default.
464
+ if (!argv) {
465
+ errors.push(server.command === undefined
466
+ ? `mcp[${i}].command is required for a stdio entry (and for an entry that declares no transport): with no command and no url there is nothing to run`
467
+ : `mcp[${i}].command must be a non-empty array of strings (argv, e.g. ["npx", "-y", "@commonlyai/mcp@latest"]); a string command is not a command line any reader in this repo executes`);
468
+ }
469
+ if (hasUrl) {
470
+ errors.push(`mcp[${i}].url must not be set for a stdio entry (and for an entry that declares no transport): a url here is a second, contradictory way to reach the server`);
471
+ }
472
+ }
473
+ });
474
+ }
475
+ }
476
+
477
+ return { ok: errors.length === 0, errors };
478
+ };
479
+
480
+ // ── resolveWorkspace ────────────────────────────────────────────────────────
481
+
482
+ /**
483
+ * Compute the workspace path and ensure it exists. On first creation, copy
484
+ * any `workspace.seed` paths in. Returns `{ path, created }` so the caller
485
+ * can log the outcome and (in the future) gate one-time setup.
486
+ *
487
+ * `envFileDir` is the directory of the env file (used to resolve relative
488
+ * seed paths). Required when `workspace.seed` contains relative entries;
489
+ * ignored otherwise.
490
+ */
491
+ export const resolveWorkspace = async (spec, agentName, envFileDir = null) => {
492
+ if (!agentName) throw new Error('resolveWorkspace requires agentName');
493
+ const declared = spec?.workspace?.path;
494
+ const path = expandHome(declared) || join(homedir(), '.commonly', 'workspaces', agentName);
495
+ const absPath = isAbsolute(path) ? path : pathResolve(path);
496
+
497
+ const created = !existsSync(absPath);
498
+ await mkdir(absPath, { recursive: true });
499
+
500
+ if (created && Array.isArray(spec?.workspace?.seed)) {
501
+ for (const entry of spec.workspace.seed) {
502
+ const src = isAbsolute(entry)
503
+ ? entry
504
+ : (envFileDir ? pathResolve(envFileDir, entry) : pathResolve(entry));
505
+ if (!existsSync(src)) {
506
+ throw new Error(`workspace.seed entry not found: ${src}`);
507
+ }
508
+ const dest = join(absPath, basename(src));
509
+ // eslint-disable-next-line no-await-in-loop
510
+ await cp(src, dest, { recursive: true, force: false, errorOnExist: false });
511
+ }
512
+ }
513
+
514
+ return { path: absPath, created };
515
+ };
516
+
517
+ // ── mountSkills ─────────────────────────────────────────────────────────────
518
+
519
+ // Marks a skill directory as a Commonly-managed snapshot. Its presence is what
520
+ // licenses a refresh to delete and re-copy the directory: without it we cannot
521
+ // distinguish "a mount we made last spawn" from "a skill the user authored in
522
+ // their workspace", and clobbering the latter would destroy real work.
523
+ const MOUNT_MARKER = '.commonly-skill-mount';
524
+
525
+ /**
526
+ * Recursively drop the write bit on every regular file in a mounted snapshot.
527
+ *
528
+ * Directories deliberately stay writable: unlinking a file requires write
529
+ * permission on its PARENT, so read-only directories would make the next
530
+ * refresh fail. This is defense-in-depth for the agent's own copy, not the
531
+ * load-bearing protection — that comes from the copy existing at all.
532
+ */
533
+ const freezeFiles = async (dir) => {
534
+ const entries = await readdir(dir, { withFileTypes: true });
535
+ for (const entry of entries) {
536
+ const full = join(dir, entry.name);
537
+ if (entry.isDirectory()) {
538
+ // eslint-disable-next-line no-await-in-loop
539
+ await freezeFiles(full);
540
+ } else if (entry.isFile()) {
541
+ // eslint-disable-next-line no-await-in-loop
542
+ await chmod(full, 0o444);
543
+ }
544
+ }
545
+ };
546
+
547
+ // F171: skills are written only into a real .claude/skills directory inside
548
+ // the workspace. Each component is read with lstat and never followed. A
549
+ // missing one is created with a plain mkdir inside the real directory just
550
+ // checked, then read again; a link, or anything that is not a real directory,
551
+ // returns null and mountSkills writes nothing.
552
+ const SKILLS_DIR_PARTS = ['.claude', 'skills'];
553
+
554
+ const lstatOrNull = async (p) => {
555
+ try {
556
+ return await lstat(p);
557
+ } catch (err) {
558
+ if (err.code === 'ENOENT') return null;
559
+ throw err;
560
+ }
561
+ };
562
+
563
+ const realSkillsDir = async (workspacePath) => {
564
+ let dir = workspacePath;
565
+ for (const part of SKILLS_DIR_PARTS) {
566
+ dir = join(dir, part);
567
+ // eslint-disable-next-line no-await-in-loop
568
+ let st = await lstatOrNull(dir);
569
+ if (!st) {
570
+ try {
571
+ // eslint-disable-next-line no-await-in-loop
572
+ await mkdir(dir);
573
+ } catch (err) {
574
+ if (err.code !== 'EEXIST') throw err;
575
+ }
576
+ // eslint-disable-next-line no-await-in-loop
577
+ st = await lstatOrNull(dir);
578
+ }
579
+ if (!st || st.isSymbolicLink() || !st.isDirectory()) return null;
580
+ }
581
+ return dir;
582
+ };
583
+
584
+ /**
585
+ * Mount each `skills.claude[]` source into `<workspacePath>/.claude/skills/`
586
+ * as a READ-ONLY SNAPSHOT COPY.
587
+ *
588
+ * Why a copy and not a symlink: a symlink is writable *through*, so an agent
589
+ * holding Write/Edit could edit the file that governs its own behaviour — and
590
+ * because these sources are usually files tracked in a repo (the bundled
591
+ * `cli/skills/commonly` is the common case), that edit lands in the operator's
592
+ * working tree. `npm publish` ships the working tree, not git HEAD, so a
593
+ * write-through edit can silently ride out in the next release. That is not
594
+ * hypothetical: @commonlyai/cli@0.1.4 shipped with the "post as yourself,
595
+ * never as your operator" guardrail (#702) missing for exactly this reason.
596
+ * The snapshot makes the data flow one-way: source → workspace, never back.
597
+ *
598
+ * Refreshed on every call — the claude adapter mounts per spawn — so edits to
599
+ * the source still reach the agent on its next turn. Only directories carrying
600
+ * MOUNT_MARKER are refreshed; anything else in the slot is reported as a
601
+ * conflict and left untouched.
602
+ *
603
+ * Legacy symlinks written by CLI ≤0.1.4 are replaced with a snapshot, since
604
+ * the symlink IS the vulnerable shape being retired.
605
+ *
606
+ * `envFileDir` is the directory of the env file (used to resolve relative
607
+ * skill paths). Optional — when omitted, relative paths fall back to
608
+ * `workspacePath`.
609
+ *
610
+ * A workspace whose .claude or .claude/skills is a link, or not a directory,
611
+ * gets no mount at all: every source is reported as 'unsafe-skill-dir' and
612
+ * nothing is written (F171).
613
+ *
614
+ * Returns `{ mounted, conflicted }`:
615
+ * - `mounted`: string[] — absolute source paths snapshotted
616
+ * - `conflicted`: Array<{path, reason}> where reason ∈
617
+ * 'not-a-mount' | 'missing-source' | 'unsafe-skill-dir'
618
+ */
619
+ export const mountSkills = async (spec, workspacePath, envFileDir = null) => {
620
+ const sources = Array.isArray(spec?.skills?.claude) ? spec.skills.claude : [];
621
+ const mounted = [];
622
+ const conflicted = [];
623
+ if (sources.length === 0) return { mounted, conflicted };
624
+
625
+ const skillsDir = await realSkillsDir(workspacePath);
626
+ if (!skillsDir) {
627
+ return {
628
+ mounted,
629
+ conflicted: sources.map((rawSource) => ({
630
+ path: resolveSkillSource(rawSource, envFileDir || workspacePath),
631
+ reason: 'unsafe-skill-dir',
632
+ })),
633
+ };
634
+ }
635
+
636
+ for (const rawSource of sources) {
637
+ const absSource = resolveSkillSource(rawSource, envFileDir || workspacePath);
638
+ if (!existsSync(absSource)) {
639
+ conflicted.push({ path: absSource, reason: 'missing-source' });
640
+ continue;
641
+ }
642
+ const destPath = join(skillsDir, basename(absSource));
643
+
644
+ let slot = null;
645
+ try {
646
+ // eslint-disable-next-line no-await-in-loop
647
+ slot = await lstat(destPath);
648
+ } catch (err) {
649
+ // Only ENOENT means "slot is free, proceed." EACCES / EPERM / EIO are
650
+ // real failures that should surface rather than becoming a confusing
651
+ // follow-on error from the copy below.
652
+ if (err.code !== 'ENOENT') throw err;
653
+ }
654
+
655
+ if (slot) {
656
+ const isLegacySymlink = slot.isSymbolicLink();
657
+ const isOwnedMount = slot.isDirectory() && existsSync(join(destPath, MOUNT_MARKER));
658
+ if (!isLegacySymlink && !isOwnedMount) {
659
+ // A real file, or a directory we did not create — user content.
660
+ conflicted.push({ path: absSource, reason: 'not-a-mount' });
661
+ continue;
662
+ }
663
+ // eslint-disable-next-line no-await-in-loop
664
+ await rm(destPath, { recursive: true, force: true });
665
+ }
666
+
667
+ // eslint-disable-next-line no-await-in-loop
668
+ await cp(absSource, destPath, { recursive: true });
669
+ // eslint-disable-next-line no-await-in-loop
670
+ await writeFile(
671
+ join(destPath, MOUNT_MARKER),
672
+ `mounted-from: ${absSource}\n`,
673
+ 'utf8',
674
+ );
675
+ // eslint-disable-next-line no-await-in-loop
676
+ await freezeFiles(destPath);
677
+ mounted.push(absSource);
678
+ }
679
+
680
+ return { mounted, conflicted };
681
+ };