@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
|
@@ -1,56 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The folder an adapter keeps one turn's credential in sits under the temp
|
|
3
|
-
* root. The sandbox denies that root by leaving it out of every grant, so a
|
|
4
|
-
* workspace that holds it undoes the denial: the workspace grant covers it,
|
|
5
|
-
* and code in the jail can read the credential and replace the turn's files
|
|
6
|
-
* (F219; measured on a real `codex sandbox`, where a workspace of $TMPDIR read
|
|
7
|
-
* the credential and a project folder could not).
|
|
8
|
-
*
|
|
9
|
-
* Checked before the folder exists, against the root, so a refused turn writes
|
|
10
|
-
* nothing. OpenCode has its own check, which also walks links and git worktrees.
|
|
11
|
-
*/
|
|
12
|
-
import { realpath } from 'fs/promises';
|
|
13
|
-
import { tmpdir } from 'os';
|
|
14
|
-
import {
|
|
15
|
-
basename, dirname, join, resolve, sep,
|
|
16
|
-
} from 'path';
|
|
17
|
-
|
|
18
|
-
export const contains = (outer, inner) => inner === outer
|
|
19
|
-
|| inner.startsWith(outer.endsWith(sep) ? outer : outer + sep);
|
|
20
|
-
|
|
21
|
-
// The real path of a path that may not exist yet: its nearest existing
|
|
22
|
-
// ancestor resolved, with the rest appended. realpath(3) also returns the
|
|
23
|
-
// disk's own spelling, which matters on a volume that folds case.
|
|
24
|
-
export const realpathOfNearest = async (path) => {
|
|
25
|
-
const rest = [];
|
|
26
|
-
let current = resolve(path);
|
|
27
|
-
for (;;) {
|
|
28
|
-
try {
|
|
29
|
-
return join(await realpath(current), ...rest);
|
|
30
|
-
} catch (err) {
|
|
31
|
-
// Only a missing path walks up. Anything else (EACCES, ELOOP) would leave
|
|
32
|
-
// the containment check comparing a path nobody resolved: fail closed.
|
|
33
|
-
if (err?.code !== 'ENOENT' && err?.code !== 'ENOTDIR') throw err;
|
|
34
|
-
const parent = dirname(current);
|
|
35
|
-
if (parent === current) return join(current, ...rest);
|
|
36
|
-
rest.unshift(basename(current));
|
|
37
|
-
current = parent;
|
|
38
|
-
}
|
|
39
|
-
}
|
|
40
|
-
};
|
|
41
|
-
|
|
42
|
-
/** Throws when `workspace` holds the temp root; returns its real path otherwise. */
|
|
43
|
-
export const refuseWorkspaceHoldingTurnDirs = async ({
|
|
44
|
-
workspace, seat, tempRoot = tmpdir(),
|
|
45
|
-
}) => {
|
|
46
|
-
const [realWorkspace, realRoot] = await Promise.all([
|
|
47
|
-
realpathOfNearest(workspace || process.cwd()),
|
|
48
|
-
realpathOfNearest(tempRoot),
|
|
49
|
-
]);
|
|
50
|
-
if (contains(realWorkspace, realRoot)) {
|
|
51
|
-
throw new Error(`${seat} cannot run in ${workspace || process.cwd()}: it contains the temp folder (${tempRoot}) `
|
|
52
|
-
+ 'where each turn keeps the agent\'s credential, which the sandbox would then expose. '
|
|
53
|
-
+ 'Run it in a project folder, or set TMPDIR outside it.');
|
|
54
|
-
}
|
|
55
|
-
return realWorkspace;
|
|
56
|
-
};
|
|
@@ -1,183 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* D245: the user's own opt-in to run an agent without a sandbox on native
|
|
3
|
-
* Windows.
|
|
4
|
-
*
|
|
5
|
-
* Commonly confines a public seat with Seatbelt on macOS and bubblewrap on
|
|
6
|
-
* Linux (mode.js). Windows has neither, so without this a public Claude Code,
|
|
7
|
-
* OpenCode or Qwen seat is refused on Windows (host-check.js). The opt-in
|
|
8
|
-
* keeps that refusal as the default and lets the person at the keyboard choose
|
|
9
|
-
* otherwise, for this machine only:
|
|
10
|
-
*
|
|
11
|
-
* - `commonly agent run <name> --unsandboxed` for one foreground run, or
|
|
12
|
-
* - `commonly daemon install --unsandboxed`, which writes the marker below; the
|
|
13
|
-
* seats that daemon spawns read it. Installing again without the flag, or
|
|
14
|
-
* uninstalling, removes it.
|
|
15
|
-
*
|
|
16
|
-
* Only a LOCAL action sets it. Nothing the server sends (a seat's declared
|
|
17
|
-
* environment, an adopted-seat row, a pod's settings) can create the marker or
|
|
18
|
-
* stand in for the flag: `hostUnconfinedForRun` takes the decision as an
|
|
19
|
-
* argument, and the adapter reads it from its spawn context, never from the
|
|
20
|
-
* environment.
|
|
21
|
-
*
|
|
22
|
-
* Only the OS jail is skipped. The seat keeps its trust, and with it the
|
|
23
|
-
* public tool policy (--setting-sources '', --strict-mcp-config, dontAsk,
|
|
24
|
-
* Read(./**), the denied tools), the sanitized environment and the TASK-294
|
|
25
|
-
* per-turn placement: on a host with no OS isolation that policy IS the
|
|
26
|
-
* sandbox. An earlier form of this rewrote the environment to `mode: 'none'`,
|
|
27
|
-
* which dropped the trust and the policy with it (review of #3388). The
|
|
28
|
-
* decision now travels as `hostUnconfined` on the adapter's spawn context,
|
|
29
|
-
* set only by `agent run`, so the environment, stored or derived, never
|
|
30
|
-
* carries it and nothing the server sends can set it.
|
|
31
|
-
*
|
|
32
|
-
* Claude Code keeps its public policy without the jail. OpenCode and Qwen stay
|
|
33
|
-
* refused on Windows. Codex has no policy beneath its permission profile, which
|
|
34
|
-
* its unelevated Windows sandbox cannot enforce, so an unconfined Codex is a
|
|
35
|
-
* full shell. It therefore gets a narrower opt-in (codexUnconfinedForRun): only
|
|
36
|
-
* a seat whose every pod is confirmed private, re-read before every turn
|
|
37
|
-
* (unconfinedTurnPlacement), and a public one always refuses (host-check.js).
|
|
38
|
-
* No seat on native Windows gets the grant broker (grant-broker-guard.js), and
|
|
39
|
-
* the server refuses grant use for every seat on a machine whose daemon reports
|
|
40
|
-
* the opt-in.
|
|
41
|
-
*/
|
|
42
|
-
|
|
43
|
-
import { existsSync, readFileSync, rmSync } from 'fs';
|
|
44
|
-
import { homedir } from 'os';
|
|
45
|
-
import { join } from 'path';
|
|
46
|
-
|
|
47
|
-
import { writeServiceFile } from '../daemon-service.js';
|
|
48
|
-
import { effectiveSandboxTrust } from '../environment.js';
|
|
49
|
-
import { allPodsConfirmedPrivate } from '../workspace-trust.js';
|
|
50
|
-
import { UNSANDBOXED_FLAG } from './host-check.js';
|
|
51
|
-
|
|
52
|
-
export { UNSANDBOXED_FLAG };
|
|
53
|
-
|
|
54
|
-
export const unsandboxedMarkerPath = (home = homedir()) => join(home, '.commonly', 'daemon', 'unsandboxed.json');
|
|
55
|
-
|
|
56
|
-
/**
|
|
57
|
-
* The flag answers a gap in the sandbox: native Windows has none, and a private
|
|
58
|
-
* Codex agent on Linux has none where bubblewrap can't start (F387, F444). So
|
|
59
|
-
* on Linux it is accepted only for `agent run` of a Codex agent: pass `adapterName` once it is known,
|
|
60
|
-
* or `deferred` before the record is read (the run checks again after). The
|
|
61
|
-
* daemon's marker stays Windows-only.
|
|
62
|
-
*/
|
|
63
|
-
export const unsandboxedFlagRefusal = (platform = process.platform, { adapterName = null, deferred = false } = {}) => {
|
|
64
|
-
if (platform === 'win32') return null;
|
|
65
|
-
if (platform === 'linux' && (adapterName === 'codex' || (deferred && adapterName === null))) return null;
|
|
66
|
-
return `${UNSANDBOXED_FLAG} is only for native Windows, which has no sandbox for agents yet, and for`
|
|
67
|
-
+ ` \`agent run\` of a private Codex agent on Linux. On this machine this agent runs in a sandbox.`;
|
|
68
|
-
};
|
|
69
|
-
|
|
70
|
-
export const loadUnsandboxedOptIn = ({
|
|
71
|
-
platform = process.platform, home = homedir(), exists = existsSync, read = readFileSync,
|
|
72
|
-
} = {}) => {
|
|
73
|
-
if (platform !== 'win32') return false;
|
|
74
|
-
const file = unsandboxedMarkerPath(home);
|
|
75
|
-
if (!exists(file)) return false;
|
|
76
|
-
try {
|
|
77
|
-
const value = JSON.parse(read(file, 'utf8'));
|
|
78
|
-
return value?.unsandboxed === true && value?.platform === 'win32';
|
|
79
|
-
} catch {
|
|
80
|
-
return false;
|
|
81
|
-
}
|
|
82
|
-
};
|
|
83
|
-
|
|
84
|
-
export const saveUnsandboxedOptIn = ({ home = homedir(), writeFile = writeServiceFile, now = () => new Date() } = {}) => {
|
|
85
|
-
writeFile(unsandboxedMarkerPath(home), `${JSON.stringify({ unsandboxed: true, platform: 'win32', optedInAt: now().toISOString() })}\n`);
|
|
86
|
-
};
|
|
87
|
-
|
|
88
|
-
export const clearUnsandboxedOptIn = ({ home = homedir(), exists = existsSync, remove = rmSync } = {}) => {
|
|
89
|
-
const file = unsandboxedMarkerPath(home);
|
|
90
|
-
if (!exists(file)) return false;
|
|
91
|
-
remove(file);
|
|
92
|
-
return true;
|
|
93
|
-
};
|
|
94
|
-
|
|
95
|
-
/**
|
|
96
|
-
* Did the user opt in for THIS run? The typed flag, or, for a seat the daemon
|
|
97
|
-
* spawned, the marker that `daemon install --unsandboxed` wrote. A foreground
|
|
98
|
-
* run never reads the marker, so installing the daemon unsandboxed does not
|
|
99
|
-
* quietly change what a hand-started `agent run` does.
|
|
100
|
-
*/
|
|
101
|
-
export const runOptedInUnsandboxed = ({ flag, env = process.env, load = loadUnsandboxedOptIn } = {}) => (
|
|
102
|
-
flag === true || (env.COMMONLY_MANAGED_RUNNER === '1' && load() === true)
|
|
103
|
-
);
|
|
104
|
-
|
|
105
|
-
/** The adapters that can run their trust's tool policy without the OS jail. */
|
|
106
|
-
export const POLICY_ONLY_ADAPTERS = new Set(['claude']);
|
|
107
|
-
|
|
108
|
-
/**
|
|
109
|
-
* Does this run skip the OS jail? Only on win32, only by the user's own
|
|
110
|
-
* opt-in, only for an adapter that keeps its policy without the jail, and only
|
|
111
|
-
* for a seat whose trust brings that policy (public or workspace). A seat with
|
|
112
|
-
* no confined trust is the owner's own and is never jailed anywhere, so the
|
|
113
|
-
* opt-in changes nothing for it, and the notice must not promise it a policy.
|
|
114
|
-
*/
|
|
115
|
-
export const hostUnconfinedForRun = ({
|
|
116
|
-
optedIn, adapterName, environment, platform = process.platform,
|
|
117
|
-
}) => {
|
|
118
|
-
// The effective trust, as host-check.js and the claude adapter read it: a
|
|
119
|
-
// legacy `internal` record means `public`, so it gets the same opt-in.
|
|
120
|
-
const trust = effectiveSandboxTrust(environment?.sandbox?.trust);
|
|
121
|
-
return platform === 'win32' && optedIn === true && POLICY_ONLY_ADAPTERS.has(adapterName)
|
|
122
|
-
&& (trust === 'public' || trust === 'workspace');
|
|
123
|
-
};
|
|
124
|
-
|
|
125
|
-
/**
|
|
126
|
-
* Does this Codex run go unconfined? Only on win32, or on Linux (F387, F444),
|
|
127
|
-
* only by the user's own opt-in, and only when every pod the seat answers in is confirmed private:
|
|
128
|
-
* `installations` is the server's read of them, and an empty or failed read is
|
|
129
|
-
* a no. Workspace trust stays with Codex's own refusal. The caller removes the
|
|
130
|
-
* sandbox block for this run only, so the saved record keeps its public trust
|
|
131
|
-
* and a later run without the flag refuses as before.
|
|
132
|
-
*/
|
|
133
|
-
export const codexUnconfinedForRun = ({
|
|
134
|
-
optedIn, adapterName, environment, installations, platform = process.platform,
|
|
135
|
-
}) => (platform === 'win32' || platform === 'linux') && optedIn === true && adapterName === 'codex'
|
|
136
|
-
&& effectiveSandboxTrust(environment?.sandbox?.trust) !== 'workspace'
|
|
137
|
-
&& allPodsConfirmedPrivate(installations);
|
|
138
|
-
|
|
139
|
-
/**
|
|
140
|
-
* What the daemon's check-in says about this host. The server refuses grant
|
|
141
|
-
* use for every seat on a machine that reports either Windows value.
|
|
142
|
-
*/
|
|
143
|
-
export const hostSandboxReport = ({ platform = process.platform, load = () => loadUnsandboxedOptIn({ platform }) } = {}) => {
|
|
144
|
-
if (platform !== 'win32') return 'default';
|
|
145
|
-
return load() === true ? 'unsandboxed-opted-in' : 'native-windows';
|
|
146
|
-
};
|
|
147
|
-
|
|
148
|
-
/** One plain line for the run log and `daemon status`. */
|
|
149
|
-
export const UNSANDBOXED_NOTICE = 'unsandboxed (opted in): Windows has no sandbox, so nothing at the OS level confines this agent. It keeps Space\'s restricted tool settings, gets no shell even with --allow write, and gets no connector grants.';
|
|
150
|
-
|
|
151
|
-
/** Codex has no restricted tool settings to keep, so its line says what it costs instead. */
|
|
152
|
-
export const CODEX_UNSANDBOXED_NOTICE = 'unsandboxed (opted in): Windows has no sandbox Codex can use here, so nothing confines this agent. It runs with your own access to files and commands, and anyone in its pods, people invited later included, can direct it. It gets no connector grants, and a turn is refused if a pod stops being invite-only.';
|
|
153
|
-
|
|
154
|
-
export const CODEX_LINUX_UNSANDBOXED_NOTICE = 'unsandboxed (opted in): you ran this Codex agent without its sandbox, so nothing confines this agent. It runs with your own access to files and commands, and anyone in its pods, people invited later included, can direct it. It gets no connector grants, and a turn is refused if a pod stops being invite-only.';
|
|
155
|
-
|
|
156
|
-
export const codexUnsandboxedNotice = (platform = process.platform) => (platform === 'linux'
|
|
157
|
-
? CODEX_LINUX_UNSANDBOXED_NOTICE
|
|
158
|
-
: CODEX_UNSANDBOXED_NOTICE);
|
|
159
|
-
|
|
160
|
-
// A 404 here is a server from before this route, which reads like a missing agent.
|
|
161
|
-
export const hostReportFailureMessage = (err) => {
|
|
162
|
-
const hint = err?.status === 404
|
|
163
|
-
? '. This server is older than this CLI: update the server, or run the agent in a sandbox.'
|
|
164
|
-
: '';
|
|
165
|
-
return `could not tell the server this agent has no sandbox, so it did not start: ${err?.message}${hint}`;
|
|
166
|
-
};
|
|
167
|
-
|
|
168
|
-
export default {
|
|
169
|
-
CODEX_LINUX_UNSANDBOXED_NOTICE,
|
|
170
|
-
CODEX_UNSANDBOXED_NOTICE,
|
|
171
|
-
codexUnsandboxedNotice,
|
|
172
|
-
UNSANDBOXED_FLAG,
|
|
173
|
-
UNSANDBOXED_NOTICE,
|
|
174
|
-
codexUnconfinedForRun,
|
|
175
|
-
clearUnsandboxedOptIn,
|
|
176
|
-
loadUnsandboxedOptIn,
|
|
177
|
-
runOptedInUnsandboxed,
|
|
178
|
-
saveUnsandboxedOptIn,
|
|
179
|
-
unsandboxedFlagRefusal,
|
|
180
|
-
unsandboxedMarkerPath,
|
|
181
|
-
hostUnconfinedForRun,
|
|
182
|
-
hostSandboxReport,
|
|
183
|
-
};
|
|
@@ -1,243 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Refuses a public Codex seat's workspace when its repository is set up to run
|
|
3
|
-
* programs.
|
|
4
|
-
*
|
|
5
|
-
* Codex runs git itself (status and diff of the workspace) outside the seat's
|
|
6
|
-
* permission profile, so whatever the repository's own git setup tells git to
|
|
7
|
-
* run, runs as the operator. `git clone` never copies a remote's config or
|
|
8
|
-
* hooks, so a repository the operator cloned carries none of this. A folder
|
|
9
|
-
* that was copied, unpacked or handed over can, and nothing about the folder
|
|
10
|
-
* says which it was.
|
|
11
|
-
*
|
|
12
|
-
* So before a turn, git itself is asked which repository it would open from the
|
|
13
|
-
* workspace and what config that repository gives it. Asking git rather than
|
|
14
|
-
* walking for `.git` matters: git also accepts a bare layout, skips a `.git` it
|
|
15
|
-
* can't use, and follows `.git` files and symlinks. The questions asked
|
|
16
|
-
* (`rev-parse`, `config --list`, `ls-files`) parse and print, and run with
|
|
17
|
-
* fsmonitor and hooks forced off on the command line, so the check can't be
|
|
18
|
-
* turned against the host. The same is done for every submodule recorded in
|
|
19
|
-
* the index that is checked out, since `git status` recurses into them.
|
|
20
|
-
*
|
|
21
|
-
* The turn is refused when any of these repositories sets a key that names a
|
|
22
|
-
* program or another config file, or holds a hook. The names are reported,
|
|
23
|
-
* never the values. When git's answer can't be read, the turn is refused too.
|
|
24
|
-
*
|
|
25
|
-
* Hook managers (husky's `core.hooksPath`, lefthook, pre-commit, git-lfs's
|
|
26
|
-
* hooks) are refused like any other: a later commit would run them as the
|
|
27
|
-
* operator. A seat that needs one of those repositories needs that setup
|
|
28
|
-
* removed from its copy.
|
|
29
|
-
*/
|
|
30
|
-
import { spawnSync } from 'child_process';
|
|
31
|
-
import { existsSync, readdirSync } from 'fs';
|
|
32
|
-
import { isAbsolute, join, resolve } from 'path';
|
|
33
|
-
|
|
34
|
-
// Keys whose value names a program git may run, or another file whose keys it
|
|
35
|
-
// would then read. Matched on the key as git prints it: section and key lower
|
|
36
|
-
// case, subsection as written. Bare-boolean keys are in BOOLEAN_KEYS below.
|
|
37
|
-
const PROGRAM_KEYS = [
|
|
38
|
-
/^filter\..+\.(clean|smudge|process)$/i,
|
|
39
|
-
/^core\.(sshcommand|pager|editor|askpass|hookspath|gitproxy|alternaterefscommand|worktree)$/i,
|
|
40
|
-
/^diff\.external$/i,
|
|
41
|
-
/^diff\..+\.(command|textconv)$/i,
|
|
42
|
-
/^merge\..+\.driver$/i,
|
|
43
|
-
/^(mergetool|difftool)\..+\.(cmd|path)$/i,
|
|
44
|
-
/^pager\./i,
|
|
45
|
-
/^gpg\.(program|.+\.program)$/i,
|
|
46
|
-
/^gpg\.ssh\.defaultkeycommand$/i,
|
|
47
|
-
/^credential\.(helper|.+\.helper)$/i,
|
|
48
|
-
/^sequence\.editor$/i,
|
|
49
|
-
/^interactive\.difffilter$/i,
|
|
50
|
-
/^include\.path$/i,
|
|
51
|
-
/^includeif\..+\.path$/i,
|
|
52
|
-
/^hook\./i,
|
|
53
|
-
/^gc\.recentobjectshook$/i,
|
|
54
|
-
/^remote\..+\.(uploadpack|receivepack|proxy|vcs)$/i,
|
|
55
|
-
/^uploadpack\.packobjectshook$/i,
|
|
56
|
-
/^protocol\.allow$/i,
|
|
57
|
-
/^protocol\..+\.allow$/i,
|
|
58
|
-
/^sendemail\.(.+\.)?(smtpserver|tocmd|cccmd|sendmailcmd|headercmd)$/i,
|
|
59
|
-
/^imap\.tunnel$/i,
|
|
60
|
-
/^guitool\..+\.cmd$/i,
|
|
61
|
-
/^instaweb\.httpd$/i,
|
|
62
|
-
/^(web|help)\.browser$/i,
|
|
63
|
-
/^(browser|man)\..+\.(cmd|path)$/i,
|
|
64
|
-
/^trailer\..+\.(command|cmd)$/i,
|
|
65
|
-
/^lfs\.extension\..+\.(clean|smudge)$/i,
|
|
66
|
-
/^lfs\.customtransfer\..+\.path$/i,
|
|
67
|
-
];
|
|
68
|
-
// core.fsmonitor is a program unless it is a boolean (true starts git's own
|
|
69
|
-
// daemon, which runs nothing from the repository).
|
|
70
|
-
const BOOLEAN_KEYS = [/^core\.fsmonitor$/i];
|
|
71
|
-
const BOOLEAN_VALUES = new Set(['', 'true', 'false', 'yes', 'no', 'on', 'off', '1', '0']);
|
|
72
|
-
// Keys that run a program only when their value says so.
|
|
73
|
-
const BANG_VALUE_KEYS = [/^alias\./i, /^submodule\..+\.update$/i];
|
|
74
|
-
const EXT_URL = /^ext::/i;
|
|
75
|
-
|
|
76
|
-
export const codeRunningKey = (key, value = '') => {
|
|
77
|
-
if (PROGRAM_KEYS.some((pattern) => pattern.test(key))) return true;
|
|
78
|
-
if (BOOLEAN_KEYS.some((pattern) => pattern.test(key))) return !BOOLEAN_VALUES.has(String(value).trim().toLowerCase());
|
|
79
|
-
if (BANG_VALUE_KEYS.some((pattern) => pattern.test(key))) return String(value).trim().startsWith('!');
|
|
80
|
-
if (/^(remote|submodule)\..+\.(url|pushurl)$/i.test(key)) return EXT_URL.test(String(value).trim());
|
|
81
|
-
if (/^url\..+\.(insteadof|pushinsteadof)$/i.test(key)) return EXT_URL.test(key.slice(4));
|
|
82
|
-
return false;
|
|
83
|
-
};
|
|
84
|
-
|
|
85
|
-
// What git reads from the environment to pick a repository and its index.
|
|
86
|
-
// Passed through, so the check opens the repository Codex's git would.
|
|
87
|
-
const DISCOVERY_ENV = [
|
|
88
|
-
'GIT_DIR', 'GIT_WORK_TREE', 'GIT_COMMON_DIR', 'GIT_INDEX_FILE', 'GIT_CEILING_DIRECTORIES', 'GIT_DISCOVERY_ACROSS_FILESYSTEM',
|
|
89
|
-
];
|
|
90
|
-
// The ones git clears before it runs inside a submodule (its local_repo_env):
|
|
91
|
-
// they name the top repository, so a submodule is opened from its own folder.
|
|
92
|
-
const TOP_REPOSITORY_ENV = ['GIT_DIR', 'GIT_WORK_TREE', 'GIT_COMMON_DIR', 'GIT_INDEX_FILE'];
|
|
93
|
-
const submoduleEnv = (env) => Object.fromEntries(Object.entries(env).filter(([name]) => !TOP_REPOSITORY_ENV.includes(name)));
|
|
94
|
-
// On the command line, ahead of anything the repository says: nothing these
|
|
95
|
-
// commands do may start fsmonitor or a hook.
|
|
96
|
-
const SAFE_FLAGS = ['-c', 'core.fsmonitor=false', '-c', 'core.hooksPath=/dev/null', '-c', 'core.untrackedCache=false'];
|
|
97
|
-
const MAX_DEPTH = 8;
|
|
98
|
-
|
|
99
|
-
const gitEnv = (baseEnv) => {
|
|
100
|
-
const env = { PATH: baseEnv.PATH || process.env.PATH, GIT_CONFIG_NOSYSTEM: '1', GIT_CONFIG_GLOBAL: '/dev/null' };
|
|
101
|
-
for (const name of DISCOVERY_ENV) if (baseEnv[name] !== undefined) env[name] = baseEnv[name];
|
|
102
|
-
return env;
|
|
103
|
-
};
|
|
104
|
-
|
|
105
|
-
const runGit = (run, cwd, env, args, { flags = SAFE_FLAGS } = {}) => run('git', [...flags, ...args], {
|
|
106
|
-
cwd, env, encoding: 'utf8', timeout: 10_000, maxBuffer: 16 * 1024 * 1024,
|
|
107
|
-
});
|
|
108
|
-
|
|
109
|
-
// The repository git opens from `dir`, as git reports it: null when `dir` is in
|
|
110
|
-
// none, or `{ unreadable }` when git could not say.
|
|
111
|
-
const describeRepository = (dir, { run, env }) => {
|
|
112
|
-
const result = runGit(run, dir, env, ['rev-parse', '--path-format=absolute', '--absolute-git-dir', '--git-common-dir', '--git-path', 'hooks']);
|
|
113
|
-
if (result?.status !== 0) {
|
|
114
|
-
// No git on the host means Codex can't run git either.
|
|
115
|
-
if (result?.error?.code === 'ENOENT') return null;
|
|
116
|
-
// Only git's "nothing here or above" answer means no repository. A `.git`
|
|
117
|
-
// file pointing nowhere also says "not a git repository", with the path.
|
|
118
|
-
if (/not a git repository \(or any/i.test(String(result?.stderr || ''))) return null;
|
|
119
|
-
return { unreadable: `git could not read the repository (${String(result?.stderr || result?.error?.message || 'no answer').trim().split('\n')[0]})` };
|
|
120
|
-
}
|
|
121
|
-
const [gitDir, commonDir, hooksDirRaw] = String(result.stdout).trim().split('\n');
|
|
122
|
-
if (!gitDir || !commonDir || !hooksDirRaw) return { unreadable: 'git gave no repository path' };
|
|
123
|
-
// --git-path answers with core.hooksPath forced to /dev/null above, so the
|
|
124
|
-
// repository's own hooks folder is read without it.
|
|
125
|
-
const hooksDir = hooksDirRaw === '/dev/null' ? join(commonDir, 'hooks') : hooksDirRaw;
|
|
126
|
-
return { gitDir, commonDir, hooksDir: isAbsolute(hooksDir) ? hooksDir : resolve(dir, hooksDir) };
|
|
127
|
-
};
|
|
128
|
-
|
|
129
|
-
// Every config entry git applies in this repository, with the system and
|
|
130
|
-
// global files off: the repository's own config, its worktree config, and
|
|
131
|
-
// whatever they include.
|
|
132
|
-
const repositoryConfig = (dir, { run, env }) => {
|
|
133
|
-
const result = runGit(run, dir, env, ['config', '--list', '--includes', '-z', '--show-origin']);
|
|
134
|
-
if (result?.status !== 0) return null;
|
|
135
|
-
// With --show-origin and -z each entry is "origin\0key\nvalue\0".
|
|
136
|
-
const parts = String(result.stdout || '').split('\0');
|
|
137
|
-
const entries = [];
|
|
138
|
-
for (let i = 0; i + 1 < parts.length; i += 2) {
|
|
139
|
-
const origin = parts[i];
|
|
140
|
-
const entry = parts[i + 1];
|
|
141
|
-
// The safe flags this check passes come back as "command line:" entries.
|
|
142
|
-
if (!entry || origin.startsWith('command line:')) continue;
|
|
143
|
-
const at = entry.indexOf('\n');
|
|
144
|
-
entries.push({
|
|
145
|
-
origin: origin.replace(/^file:/, ''),
|
|
146
|
-
key: at < 0 ? entry : entry.slice(0, at),
|
|
147
|
-
value: at < 0 ? '' : entry.slice(at + 1),
|
|
148
|
-
});
|
|
149
|
-
}
|
|
150
|
-
return entries;
|
|
151
|
-
};
|
|
152
|
-
|
|
153
|
-
// Hook names in `dir`, or null when it exists but can't be listed.
|
|
154
|
-
const hooksIn = (dir) => {
|
|
155
|
-
if (!existsSync(dir)) return [];
|
|
156
|
-
try {
|
|
157
|
-
return readdirSync(dir).filter((name) => !name.endsWith('.sample'));
|
|
158
|
-
} catch {
|
|
159
|
-
return null;
|
|
160
|
-
}
|
|
161
|
-
};
|
|
162
|
-
|
|
163
|
-
// Checked-out submodules recorded in the index, as paths below `top`.
|
|
164
|
-
const submodulesOf = (top, { run, env }) => {
|
|
165
|
-
const result = runGit(run, top, env, ['ls-files', '--stage', '-z']);
|
|
166
|
-
if (result?.status !== 0) return null;
|
|
167
|
-
return String(result.stdout || '').split('\0').filter((line) => line.startsWith('160000 '))
|
|
168
|
-
.map((line) => line.slice(line.indexOf('\t') + 1))
|
|
169
|
-
.map((path) => join(top, path))
|
|
170
|
-
.filter((path) => existsSync(join(path, '.git')));
|
|
171
|
-
};
|
|
172
|
-
|
|
173
|
-
const topLevelOf = (dir, { run, env }) => {
|
|
174
|
-
const result = runGit(run, dir, env, ['rev-parse', '--show-toplevel']);
|
|
175
|
-
return result?.status === 0 ? String(result.stdout).trim() || null : null;
|
|
176
|
-
};
|
|
177
|
-
|
|
178
|
-
const inspect = (dir, context, findings, seen, depth) => {
|
|
179
|
-
const repo = describeRepository(dir, context);
|
|
180
|
-
if (!repo) return;
|
|
181
|
-
if (repo.unreadable) {
|
|
182
|
-
findings.push({ file: dir, key: repo.unreadable });
|
|
183
|
-
return;
|
|
184
|
-
}
|
|
185
|
-
if (seen.has(repo.gitDir)) return;
|
|
186
|
-
seen.add(repo.gitDir);
|
|
187
|
-
if (depth > MAX_DEPTH) {
|
|
188
|
-
findings.push({ file: dir, key: `submodules nested deeper than ${MAX_DEPTH}` });
|
|
189
|
-
return;
|
|
190
|
-
}
|
|
191
|
-
const entries = repositoryConfig(dir, context);
|
|
192
|
-
if (!entries) {
|
|
193
|
-
findings.push({ file: join(repo.commonDir, 'config'), key: 'unreadable config' });
|
|
194
|
-
} else {
|
|
195
|
-
for (const { origin, key, value } of entries) {
|
|
196
|
-
if (codeRunningKey(key, value)) findings.push({ file: origin, key });
|
|
197
|
-
}
|
|
198
|
-
}
|
|
199
|
-
for (const hooksDir of new Set([repo.hooksDir, join(repo.gitDir, 'hooks'), join(repo.commonDir, 'hooks')])) {
|
|
200
|
-
const hooks = hooksIn(hooksDir);
|
|
201
|
-
if (!hooks) findings.push({ file: hooksDir, key: 'unreadable hooks folder' });
|
|
202
|
-
else for (const hook of hooks) findings.push({ file: join(hooksDir, hook), hook });
|
|
203
|
-
}
|
|
204
|
-
const top = topLevelOf(dir, context);
|
|
205
|
-
if (!top) return; // a bare repository has no work tree, so no submodules are checked out
|
|
206
|
-
const submodules = submodulesOf(top, context);
|
|
207
|
-
if (!submodules) {
|
|
208
|
-
findings.push({ file: top, key: 'unreadable index' });
|
|
209
|
-
return;
|
|
210
|
-
}
|
|
211
|
-
const subContext = { ...context, env: submoduleEnv(context.env) };
|
|
212
|
-
for (const sub of submodules) inspect(sub, subContext, findings, seen, depth + 1);
|
|
213
|
-
};
|
|
214
|
-
|
|
215
|
-
/**
|
|
216
|
-
* What would make git run a program in the workspace's repository or its
|
|
217
|
-
* checked-out submodules: each a `{ file, key }` (never the value), or a
|
|
218
|
-
* `{ file, hook }`. Empty when the workspace is in no repository, or nothing
|
|
219
|
-
* is set. `env` is the environment Codex will get; only the variables git uses
|
|
220
|
-
* to pick a repository are taken from it.
|
|
221
|
-
*/
|
|
222
|
-
export const codeRunningGitSetup = (workspace, { run = spawnSync, env = process.env } = {}) => {
|
|
223
|
-
const findings = [];
|
|
224
|
-
inspect(resolve(workspace), { run, env: gitEnv(env) }, findings, new Set(), 0);
|
|
225
|
-
return findings;
|
|
226
|
-
};
|
|
227
|
-
|
|
228
|
-
export const refuseCodeRunningGitSetup = (workspace, { seat = 'this agent', run, env } = {}) => {
|
|
229
|
-
const findings = codeRunningGitSetup(workspace, { run, env });
|
|
230
|
-
if (!findings.length) return;
|
|
231
|
-
const named = findings.slice(0, 8).map((f) => (f.hook ? `hook ${f.hook} (${f.file})` : `${f.key} (${f.file})`)).join('; ');
|
|
232
|
-
const more = findings.length > 8 ? ` and ${findings.length - 8} more` : '';
|
|
233
|
-
const error = new Error(
|
|
234
|
-
`${seat} won't work in ${workspace}: its git repository is set to run programs, or git couldn't `
|
|
235
|
-
+ `say (${named}${more}). Codex runs git outside the sandbox, so these would run as you. Remove that setup from the `
|
|
236
|
-
+ 'agent\'s copy (unset the keys, delete the hooks), or give it a fresh `git clone` that no hook '
|
|
237
|
-
+ 'manager has been installed into.',
|
|
238
|
-
);
|
|
239
|
-
error.code = 'workspace_git_runs_programs';
|
|
240
|
-
throw error;
|
|
241
|
-
};
|
|
242
|
-
|
|
243
|
-
export default { codeRunningGitSetup, codeRunningKey, refuseCodeRunningGitSetup };
|
|
@@ -1,158 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The realpath a workspace-trust seat's folder had when the seat first ran
|
|
3
|
-
* there (F200).
|
|
4
|
-
*
|
|
5
|
-
* A public seat's writable folder is pinned to its own name (seatbelt.js
|
|
6
|
-
* pinnedRealpath, F197). A workspace seat's folder is the operator's choice
|
|
7
|
-
* and may be a link on purpose, so it cannot be pinned that way. But a seat
|
|
8
|
-
* with write access can remove its project folder and leave a link in its
|
|
9
|
-
* place, and the next turn's grant (a Seatbelt subpath on the resolved path,
|
|
10
|
-
* or a bwrap bind of it) would follow that link.
|
|
11
|
-
*
|
|
12
|
-
* So the folder's realpath is recorded the first time the seat runs there,
|
|
13
|
-
* and a later turn whose folder resolves anywhere else is refused before
|
|
14
|
-
* anything is spawned. A link the operator had in place before that first
|
|
15
|
-
* turn is part of what was recorded and keeps working. The record lives in a
|
|
16
|
-
* folder beside the seat's state folder, which no turn is granted: Seatbelt
|
|
17
|
-
* denies writes to it by name, and bwrap never binds it. A workspace that
|
|
18
|
-
* contains it is refused.
|
|
19
|
-
*
|
|
20
|
-
* Each folder has its own record file, created once and never rewritten: a
|
|
21
|
-
* staged copy is hard-linked to the final name, which fails if the name is
|
|
22
|
-
* taken. So two turns recording at once cannot lose each other's record, and
|
|
23
|
-
* two that disagree about one folder cannot both pass.
|
|
24
|
-
*
|
|
25
|
-
* Recording happens on the first turn, not at attach: attach does not know
|
|
26
|
-
* the seat's state folder, and no turn of the seat runs between attach and its
|
|
27
|
-
* first turn. A seat that already ran before this check existed is recorded on
|
|
28
|
-
* its first turn after the update.
|
|
29
|
-
*/
|
|
30
|
-
import {
|
|
31
|
-
link, lstat, mkdir, readFile, realpath, rm, writeFile,
|
|
32
|
-
} from 'fs/promises';
|
|
33
|
-
import { createHash, randomUUID } from 'crypto';
|
|
34
|
-
import {
|
|
35
|
-
basename, dirname, isAbsolute, join, relative, resolve, sep,
|
|
36
|
-
} from 'path';
|
|
37
|
-
|
|
38
|
-
export const workspacePinDir = (statePath) => join(dirname(statePath), 'workspace-pins');
|
|
39
|
-
export const workspacePinFile = (statePath, cwd) => {
|
|
40
|
-
const folder = createHash('sha256').update(resolve(cwd)).digest('hex').slice(0, 16);
|
|
41
|
-
return join(workspacePinDir(statePath), `${basename(statePath)}-${folder}.json`);
|
|
42
|
-
};
|
|
43
|
-
|
|
44
|
-
const isInsideOrEqual = (child, parent) => {
|
|
45
|
-
const rel = relative(parent, child);
|
|
46
|
-
return rel === '' || (rel.split(sep)[0] !== '..' && !isAbsolute(rel));
|
|
47
|
-
};
|
|
48
|
-
|
|
49
|
-
const readRecord = async (file) => {
|
|
50
|
-
let stat;
|
|
51
|
-
try {
|
|
52
|
-
stat = await lstat(file);
|
|
53
|
-
} catch (err) {
|
|
54
|
-
if (err?.code === 'ENOENT') return null;
|
|
55
|
-
throw err;
|
|
56
|
-
}
|
|
57
|
-
const unreadable = (why) => new Error(
|
|
58
|
-
`refusing a workspace turn: this agent's workspace record ${file} ${why}. `
|
|
59
|
-
+ 'First confirm the agent\'s folder resolves where you expect (for example with `realpath`), '
|
|
60
|
-
+ 'then remove the record; the next turn records the folder as it resolves then.',
|
|
61
|
-
);
|
|
62
|
-
if (!stat.isFile()) throw unreadable('is not a regular file');
|
|
63
|
-
try {
|
|
64
|
-
const parsed = JSON.parse(await readFile(file, 'utf8'));
|
|
65
|
-
if (typeof parsed?.realpath !== 'string' || !parsed.realpath) throw new Error('bad shape');
|
|
66
|
-
return parsed;
|
|
67
|
-
} catch {
|
|
68
|
-
throw unreadable('cannot be read');
|
|
69
|
-
}
|
|
70
|
-
};
|
|
71
|
-
|
|
72
|
-
// True when this call created the record, false when another had already. The
|
|
73
|
-
// final name appears only complete, because it is a link to a finished copy.
|
|
74
|
-
const createRecord = async (file, record, { _writeFile = writeFile } = {}) => {
|
|
75
|
-
await mkdir(dirname(file), { recursive: true, mode: 0o700 });
|
|
76
|
-
const staged = join(dirname(file), `.${basename(file)}.${randomUUID()}.tmp`);
|
|
77
|
-
try {
|
|
78
|
-
await _writeFile(staged, `${JSON.stringify(record, null, 2)}\n`, { encoding: 'utf8', mode: 0o600, flag: 'wx' });
|
|
79
|
-
await link(staged, file);
|
|
80
|
-
return true;
|
|
81
|
-
} catch (err) {
|
|
82
|
-
if (err?.code === 'EEXIST') return false;
|
|
83
|
-
throw err;
|
|
84
|
-
} finally {
|
|
85
|
-
await rm(staged, { force: true });
|
|
86
|
-
}
|
|
87
|
-
};
|
|
88
|
-
|
|
89
|
-
const changed = ({ given, current, recorded, file }) => new Error(
|
|
90
|
-
`refusing a workspace turn: ${given} now resolves to ${current}, but it resolved to `
|
|
91
|
-
+ `${recorded} when this agent first ran there. A turn can replace its folder with a `
|
|
92
|
-
+ 'link, which would let the next turn write wherever the link points. If you moved the '
|
|
93
|
-
+ `project on purpose, remove ${file} (the next turn records the new location), or attach `
|
|
94
|
-
+ 'the agent to the project\'s new folder.',
|
|
95
|
-
);
|
|
96
|
-
|
|
97
|
-
/**
|
|
98
|
-
* Compare the folder's realpath with the one recorded for it, recording it
|
|
99
|
-
* when there is none, and return the realpath to grant. A folder that does not
|
|
100
|
-
* resolve is refused, recorded or not: the run loop makes the folder before
|
|
101
|
-
* each turn, so there is nothing here to grant.
|
|
102
|
-
*
|
|
103
|
-
* `_realpath` and `_writeFile` are test seams.
|
|
104
|
-
*/
|
|
105
|
-
export const holdWorkspaceRealpath = async ({
|
|
106
|
-
cwd, statePath, log = (line) => console.warn(line), _realpath = realpath, _writeFile = writeFile,
|
|
107
|
-
}) => {
|
|
108
|
-
const given = resolve(cwd);
|
|
109
|
-
const file = workspacePinFile(statePath, given);
|
|
110
|
-
let current;
|
|
111
|
-
try {
|
|
112
|
-
current = await _realpath(given);
|
|
113
|
-
} catch {
|
|
114
|
-
// Missing, or a link to nowhere.
|
|
115
|
-
const entry = await readRecord(file);
|
|
116
|
-
if (entry) throw changed({ given, current: 'nothing', recorded: entry.realpath, file });
|
|
117
|
-
throw new Error(
|
|
118
|
-
`refusing a workspace turn: ${given} does not resolve to a folder. `
|
|
119
|
-
+ 'Create the folder or point the agent at one, and run the turn again.',
|
|
120
|
-
);
|
|
121
|
-
}
|
|
122
|
-
const pinDir = workspacePinDir(statePath);
|
|
123
|
-
let realPinDir = pinDir;
|
|
124
|
-
try {
|
|
125
|
-
realPinDir = await realpath(pinDir);
|
|
126
|
-
} catch {
|
|
127
|
-
try {
|
|
128
|
-
realPinDir = join(await realpath(dirname(pinDir)), basename(pinDir));
|
|
129
|
-
} catch {
|
|
130
|
-
// Neither exists yet; compared as given.
|
|
131
|
-
}
|
|
132
|
-
}
|
|
133
|
-
if (isInsideOrEqual(realPinDir, current)) {
|
|
134
|
-
throw new Error(
|
|
135
|
-
`refusing a workspace turn: ${given} contains this agent's own record of its folder (${pinDir}), `
|
|
136
|
-
+ 'which the agent must not be able to write. Point the agent at a project folder instead.',
|
|
137
|
-
);
|
|
138
|
-
}
|
|
139
|
-
const held = async (entry) => {
|
|
140
|
-
if (entry?.realpath !== current) {
|
|
141
|
-
throw changed({ given, current, recorded: entry?.realpath ?? 'nothing', file });
|
|
142
|
-
}
|
|
143
|
-
return current;
|
|
144
|
-
};
|
|
145
|
-
const existing = await readRecord(file);
|
|
146
|
-
if (existing) return held(existing);
|
|
147
|
-
const created = await createRecord(file, {
|
|
148
|
-
workspace: given, realpath: current, recordedAt: new Date().toISOString(),
|
|
149
|
-
}, { _writeFile });
|
|
150
|
-
// Another turn recorded it first: this turn answers to that record.
|
|
151
|
-
if (!created) return held(await readRecord(file));
|
|
152
|
-
// Once per folder: a seat that ran before this check is recorded as its
|
|
153
|
-
// folder resolves now, which the operator should confirm.
|
|
154
|
-
log(`[claude] workspace: recorded ${given} → ${current}`);
|
|
155
|
-
return current;
|
|
156
|
-
};
|
|
157
|
-
|
|
158
|
-
export default { holdWorkspaceRealpath, workspacePinDir, workspacePinFile };
|