@ravefox/space 0.2.1 → 0.3.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.
- package/LICENSE +21 -205
- package/README.md +80 -31
- package/bin/space.js +106 -0
- package/lib/agents.js +119 -0
- package/lib/credentials.js +95 -0
- package/lib/daemon.js +294 -0
- package/lib/harness.js +166 -0
- package/lib/http.js +101 -0
- package/lib/login.js +68 -0
- package/lib/mcp.js +34 -0
- package/lib/runtime.js +241 -0
- package/lib/store.js +96 -0
- package/lib/tools.js +93 -0
- package/package.json +27 -46
- package/NOTICE +0 -55
- package/assets/webhook-sdk/python/bot.py +0 -63
- package/assets/webhook-sdk/python/commonly.py +0 -232
- package/node_modules/@commonlyai/seats/README.md +0 -25
- package/node_modules/@commonlyai/seats/package.json +0 -22
- package/node_modules/@commonlyai/seats/src/adapters/hermes.js +0 -264
- package/node_modules/@commonlyai/seats/src/adapters/stub.js +0 -24
- package/node_modules/@commonlyai/seats/src/contract.js +0 -48
- package/node_modules/@commonlyai/seats/src/failures.js +0 -44
- package/node_modules/@commonlyai/seats/src/harness-plan.js +0 -329
- package/node_modules/@commonlyai/seats/src/index.js +0 -22
- package/node_modules/@commonlyai/seats/src/process-group.js +0 -269
- package/node_modules/@commonlyai/seats/src/record/format.js +0 -737
- package/node_modules/@commonlyai/seats/src/record/otel.js +0 -238
- package/node_modules/@commonlyai/seats/src/runtime-observation.js +0 -29
- package/node_modules/@commonlyai/seats/src/seat-model.js +0 -17
- package/node_modules/@commonlyai/seats/src/supervisor.js +0 -308
- package/node_modules/@commonlyai/seats/src/trace/events.js +0 -46
- package/node_modules/@commonlyai/seats/src/trace/store.js +0 -276
- package/node_modules/@commonlyai/seats/src/turn-processes.js +0 -226
- package/node_modules/@commonlyai/seats/src/upstream-refusal.js +0 -418
- package/node_modules/@commonlyai/seats/src/valet/env.js +0 -201
- package/node_modules/@commonlyai/seats/src/valet/grant.js +0 -123
- package/node_modules/@commonlyai/seats/src/valet/permissions.js +0 -213
- package/node_modules/@commonlyai/seats/src/wire/provider-proxy.js +0 -529
- package/skills/commonly/SKILL.md +0 -233
- package/src/commands/agent.js +0 -6334
- package/src/commands/daemon.js +0 -933
- package/src/commands/dev.js +0 -153
- package/src/commands/doctor.js +0 -141
- package/src/commands/inbox.js +0 -330
- package/src/commands/login.js +0 -194
- package/src/commands/mcp.js +0 -56
- package/src/commands/pod.js +0 -120
- package/src/commands/task.js +0 -279
- package/src/commands/verify.js +0 -48
- package/src/index.js +0 -99
- package/src/lib/adapter-login.js +0 -76
- package/src/lib/adapters/broker-bridge-entry.js +0 -76
- package/src/lib/adapters/claude.js +0 -2207
- package/src/lib/adapters/codex-broker-bridge.mjs +0 -298
- package/src/lib/adapters/codex.js +0 -1898
- package/src/lib/adapters/dsh.js +0 -513
- package/src/lib/adapters/goose.js +0 -510
- package/src/lib/adapters/hermes.js +0 -18
- package/src/lib/adapters/index.js +0 -68
- package/src/lib/adapters/opencode.js +0 -1681
- package/src/lib/adapters/pi-commonly-mcp.mjs +0 -71
- package/src/lib/adapters/pi-mcp-client.mjs +0 -617
- package/src/lib/adapters/pi-shell-env.mjs +0 -41
- package/src/lib/adapters/pi.js +0 -1025
- package/src/lib/adapters/qwen.js +0 -523
- package/src/lib/adapters/seat-model.js +0 -8
- package/src/lib/adapters/stub.js +0 -1
- package/src/lib/adapters/tested-version.js +0 -106
- package/src/lib/api.js +0 -205
- package/src/lib/claim-outcome.js +0 -128
- package/src/lib/claimed-work-gate.js +0 -73
- package/src/lib/cli-hint.js +0 -33
- package/src/lib/commit-attribution.js +0 -391
- package/src/lib/config.js +0 -155
- package/src/lib/content-refusal.js +0 -94
- package/src/lib/credential-file.js +0 -208
- package/src/lib/daemon-agent-states.js +0 -95
- package/src/lib/daemon-hint.js +0 -18
- package/src/lib/daemon-logs.js +0 -43
- package/src/lib/daemon-service-windows.js +0 -263
- package/src/lib/daemon-service.js +0 -486
- package/src/lib/daemon-state.js +0 -84
- package/src/lib/daemon-store.js +0 -119
- package/src/lib/daemon-supervisor.js +0 -629
- package/src/lib/daemon-watch.js +0 -115
- package/src/lib/declared-mcp-guard.js +0 -284
- package/src/lib/default-environment.js +0 -207
- package/src/lib/device-login.js +0 -126
- package/src/lib/digest-frame.js +0 -151
- package/src/lib/doctor.js +0 -291
- package/src/lib/egress-proxy.js +0 -160
- package/src/lib/enforcement.js +0 -946
- package/src/lib/environment.js +0 -681
- package/src/lib/first-output-watchdog.js +0 -191
- package/src/lib/git-readonly-server.js +0 -138
- package/src/lib/git-readonly.js +0 -463
- package/src/lib/git-remote-commonly.mjs +0 -100
- package/src/lib/grant-broker-guard.js +0 -345
- package/src/lib/hooks-config.js +0 -238
- package/src/lib/host-gh-broker.js +0 -152
- package/src/lib/host-gh-shim.mjs +0 -98
- package/src/lib/inbox.js +0 -483
- package/src/lib/instance-proxy.js +0 -76
- package/src/lib/machine-host.js +0 -29
- package/src/lib/mcp-credential-delivery.js +0 -121
- package/src/lib/mcp-first-run.js +0 -50
- package/src/lib/mcp-home.js +0 -644
- package/src/lib/mcp-ready.js +0 -68
- package/src/lib/mcp-server-version.js +0 -123
- package/src/lib/mcp-token.js +0 -36
- package/src/lib/mcp-warm-child.mjs +0 -412
- package/src/lib/memory-bridge.js +0 -191
- package/src/lib/memory-import.js +0 -156
- package/src/lib/missing-harness.js +0 -31
- package/src/lib/model-unavailable.js +0 -154
- package/src/lib/native-runtime-inventory.js +0 -40
- package/src/lib/outsider-grant-mcp.js +0 -82
- package/src/lib/own-key-launch.js +0 -42
- package/src/lib/pod-catch-up.js +0 -55
- package/src/lib/pod-focus.js +0 -224
- package/src/lib/pod-tail.js +0 -68
- package/src/lib/poll-liveness.js +0 -103
- package/src/lib/poll-retry.js +0 -81
- package/src/lib/poller.js +0 -200
- package/src/lib/provider-proxy.js +0 -42
- package/src/lib/public-seat-mcp-flag.js +0 -36
- package/src/lib/public-tool-error.js +0 -60
- package/src/lib/public-withheld-tools.js +0 -48
- package/src/lib/reply-receipt.js +0 -55
- package/src/lib/resolve-on-path.js +0 -22
- package/src/lib/runtime-inventory.js +0 -56
- package/src/lib/runtime-observation.js +0 -37
- package/src/lib/sandbox/bwrap.js +0 -275
- package/src/lib/sandbox/codex-perimeter.js +0 -16
- package/src/lib/sandbox/host-check.js +0 -268
- package/src/lib/sandbox/mode.js +0 -49
- package/src/lib/sandbox/opencode-loopback.mjs +0 -68
- package/src/lib/sandbox/seatbelt.js +0 -684
- package/src/lib/sandbox/turn-dir.js +0 -56
- package/src/lib/sandbox/windows-unsandboxed.js +0 -183
- package/src/lib/sandbox/workspace-git-config.js +0 -243
- package/src/lib/sandbox/workspace-pin.js +0 -158
- package/src/lib/seat-runtime-controls.js +0 -61
- package/src/lib/seat-stop.js +0 -97
- package/src/lib/server-skills.js +0 -32
- package/src/lib/session-recycle.js +0 -219
- package/src/lib/session-store.js +0 -215
- package/src/lib/skill-prompt.js +0 -74
- package/src/lib/skills-import.js +0 -113
- package/src/lib/spawn-credential.js +0 -402
- package/src/lib/spawn-retry.js +0 -311
- package/src/lib/stable-install.js +0 -176
- package/src/lib/supervised-turn.js +0 -352
- package/src/lib/task.js +0 -235
- package/src/lib/turn-failure-notice.js +0 -177
- package/src/lib/unconfined-seat-guard.js +0 -94
- package/src/lib/update-check.js +0 -192
- package/src/lib/upstream-refusal.js +0 -1
- package/src/lib/webhook-server.js +0 -109
- package/src/lib/windows-command.js +0 -393
- package/src/lib/workspace-git.js +0 -87
- package/src/lib/workspace-trust.js +0 -162
package/src/lib/environment.js
DELETED
|
@@ -1,681 +0,0 @@
|
|
|
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
|
-
};
|