@starci/hfs 4.8.0 → 4.9.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/CHANGELOG.md +6 -0
- package/emit/operation-schemas.mjs +3 -0
- package/lint/bound-sys.cjs +2 -2
- package/package.json +1 -1
- package/runtime/engine/admission.mjs +1 -1
- package/runtime/engine/config.mjs +2 -2
- package/runtime/engine/db/machine.mjs +2 -3
- package/runtime/engine/invalid-config.mjs +1 -1
- package/runtime/knowledge/hfs/canon-pins.yaml +11 -10
- package/runtime/knowledge/hfs/rules.yaml +23 -0
- package/runtime/modules/kernel/failure-codes.yaml +10 -0
- package/runtime/scripts/guards/git-policy.mjs +2 -3
- package/runtime/scripts/hfs/rules/pipeline.mjs +5 -5
- package/runtime/scripts/lib/package-at.mjs +5 -6
- package/runtime/scripts/lib/roots.mjs +2 -2
- package/runtime/scripts/work/draw/draw-dna-findings.mjs +23 -12
- package/runtime/scripts/work/draw/draw-dna.mjs +14 -8
- package/scaffold/app.mjs +4 -4
- package/src/main.mjs +3 -3
- package/sync/index.mjs +14 -1
- package/sync/managed.mjs +3 -2
- package/templates/app/codegen-graphql.mjs.partial +4 -4
- package/templates/app/quality-config/sonar-project.properties +4 -2
- package/runtime/engine/canonical-json.mjs +0 -14
- package/runtime/engine/db/ledger-open.mjs +0 -38
- package/runtime/engine/db/ledger-paths.mjs +0 -87
- package/runtime/engine/db/ledger.mjs +0 -1068
- package/runtime/engine/db/provision-authority.mjs +0 -76
- package/runtime/engine/db/provision-operations.mjs +0 -146
- package/runtime/packages/grammar/scripts/build-stamp.mjs +0 -98
- package/runtime/scripts/agent/codex-app-server.mjs +0 -79
- package/runtime/scripts/agent/codex-mirror-source.mjs +0 -34
- package/runtime/scripts/agent/host-agents.mjs +0 -80
- package/runtime/scripts/agent/launch-trust-policy.mjs +0 -53
- package/runtime/scripts/agent/trust-launch.mjs +0 -191
- package/runtime/scripts/agent/trust.mjs +0 -523
- package/runtime/scripts/api/fs/temp-path.mjs +0 -8
- package/runtime/scripts/api/git/diff-names.mjs +0 -8
- package/runtime/scripts/api/git/head-time.mjs +0 -10
- package/runtime/scripts/api/git/index-lock.mjs +0 -78
- package/runtime/scripts/api/git/log.mjs +0 -5
- package/runtime/scripts/api/git/merge-tree.mjs +0 -5
- package/runtime/scripts/api/git/rev-list.mjs +0 -5
- package/runtime/scripts/api/git/rev-parse.mjs +0 -13
- package/runtime/scripts/api/git/status-query.mjs +0 -5
- package/runtime/scripts/api/git/worktree-list-query.mjs +0 -5
- package/runtime/scripts/api/node/spawn-node.mjs +0 -6
- package/runtime/scripts/api/npm/dist-tag.mjs +0 -8
- package/runtime/scripts/api/npm/lib.mjs +0 -32
- package/runtime/scripts/api/npm/pack-dry-run.mjs +0 -9
- package/runtime/scripts/api/npm/run-npm.mjs +0 -6
- package/runtime/scripts/api/npm/view.mjs +0 -30
- package/runtime/scripts/api/npm/whoami.mjs +0 -8
- package/runtime/scripts/api/process/hide-child-windows.mjs +0 -31
- package/runtime/scripts/api/process/kill-tree.mjs +0 -22
- package/runtime/scripts/api/process/lib.mjs +0 -125
- package/runtime/scripts/api/process/probe-guard-command.mjs +0 -74
- package/runtime/scripts/api/process/process-list.mjs +0 -28
- package/runtime/scripts/api/process/system-tool.mjs +0 -37
- package/runtime/scripts/gates/canon-digest.mjs +0 -81
- package/runtime/scripts/gates/canon-pins.mjs +0 -119
- package/runtime/scripts/gates/gate-input.mjs +0 -98
- package/runtime/scripts/gates/gate.mjs +0 -636
- package/runtime/scripts/gates/grammar-dist.mjs +0 -206
- package/runtime/scripts/gates/output.mjs +0 -40
- package/runtime/scripts/gates/runtime-package-selection.mjs +0 -27
- package/runtime/scripts/gates/test-world-run.mjs +0 -252
- package/runtime/scripts/gates/type-impact.mjs +0 -277
- package/runtime/scripts/gates/type-program.mjs +0 -113
- package/runtime/scripts/guards/assigned-command.mjs +0 -12
- package/runtime/scripts/guards/call-rights.mjs +0 -47
- package/runtime/scripts/guards/command-guard.mjs +0 -468
- package/runtime/scripts/guards/command-policy.mjs +0 -342
- package/runtime/scripts/guards/critic-reach.mjs +0 -82
- package/runtime/scripts/guards/deps-guard.mjs +0 -150
- package/runtime/scripts/guards/env-dump-verdict.mjs +0 -89
- package/runtime/scripts/guards/guards-root.mjs +0 -27
- package/runtime/scripts/guards/hook-install.mjs +0 -343
- package/runtime/scripts/guards/hook-verdict.mjs +0 -12
- package/runtime/scripts/guards/install-verdict.mjs +0 -41
- package/runtime/scripts/guards/kernel-seat.mjs +0 -63
- package/runtime/scripts/guards/launch-verdict.mjs +0 -74
- package/runtime/scripts/guards/op-starci.mjs +0 -16
- package/runtime/scripts/guards/op-write-scope.mjs +0 -27
- package/runtime/scripts/guards/orca-self-lifecycle.mjs +0 -39
- package/runtime/scripts/guards/process-kill-verdict.mjs +0 -106
- package/runtime/scripts/guards/protected-zone.mjs +0 -56
- package/runtime/scripts/guards/refusals.mjs +0 -27
- package/runtime/scripts/guards/rights.mjs +0 -268
- package/runtime/scripts/guards/seat-tools.mjs +0 -47
- package/runtime/scripts/guards/shell-commands.mjs +0 -328
- package/runtime/scripts/guards/supervisor-seat.mjs +0 -80
- package/runtime/scripts/guards/verdict-process.mjs +0 -81
- package/runtime/scripts/lib/app-data.mjs +0 -11
- package/runtime/scripts/lib/canon-binding-text.mjs +0 -129
- package/runtime/scripts/lib/check-format.mjs +0 -7
- package/runtime/scripts/lib/failure-code-reads.mjs +0 -100
- package/runtime/scripts/lib/failure-code-template-errors.mjs +0 -161
- package/runtime/scripts/lib/git-dir.mjs +0 -120
- package/runtime/scripts/lib/guard-command.mjs +0 -24
- package/runtime/scripts/lib/interrupt-cleanup.mjs +0 -38
- package/runtime/scripts/lib/json-formats.mjs +0 -34
- package/runtime/scripts/lib/managed-json.mjs +0 -51
- package/runtime/scripts/lib/menu-parts.mjs +0 -11
- package/runtime/scripts/lib/op-gate.mjs +0 -15
- package/runtime/scripts/lib/provision-control-failure.mjs +0 -32
- package/runtime/scripts/lib/release-version.mjs +0 -134
- package/runtime/scripts/lib/runtime-location.mjs +0 -21
- package/runtime/scripts/lib/runtime-package-inventory.mjs +0 -21
- package/runtime/scripts/lib/runtime-package-manifests.mjs +0 -84
- package/runtime/scripts/lib/runtime-publication-tags.mjs +0 -56
- package/runtime/scripts/lib/seat-env.mjs +0 -43
- package/runtime/scripts/lib/sonar-new-code.mjs +0 -58
- package/runtime/scripts/lib/source-package-imports.mjs +0 -111
- package/runtime/scripts/lib/tar-files.mjs +0 -39
- package/runtime/scripts/lib/uat-run-folder.mjs +0 -118
- package/runtime/scripts/lib/worktree-record.mjs +0 -14
- package/runtime/scripts/machine/ask-receipts.mjs +0 -113
- package/runtime/scripts/machine/evidence-store.mjs +0 -223
- package/runtime/scripts/machine/home.mjs +0 -216
- package/runtime/scripts/machine/host-lock.mjs +0 -184
- package/runtime/scripts/machine/lock-recovery.mjs +0 -55
- package/runtime/scripts/machine/roles-contract.mjs +0 -90
- package/runtime/scripts/machine/runtime-change.mjs +0 -20
- package/runtime/scripts/machine/workflow-tree.mjs +0 -122
- package/runtime/scripts/reconciler/checklist-items.mjs +0 -7
- package/runtime/scripts/reconciler/guard-row.mjs +0 -25
- package/runtime/scripts/work/brand/brand-colour.mjs +0 -125
- package/runtime/scripts/work/brand/brand-tokens.mjs +0 -167
- package/runtime/scripts/work/brand/brand-value-source.mjs +0 -106
- package/runtime/scripts/work/brand/brand.mjs +0 -892
- package/runtime/scripts/work/draw/draw-layer.mjs +0 -306
- package/runtime/scripts/work/draw/draw-rationale-page.mjs +0 -237
- package/runtime/scripts/work/draw/draw-rationale-ref.mjs +0 -14
- package/runtime/scripts/work/draw/draw-rationale.mjs +0 -353
- package/runtime/scripts/work/draw/draw-source.mjs +0 -412
- package/runtime/scripts/work/draw/draw-taste.mjs +0 -278
- package/runtime/scripts/work/draw/draw-typecheck.mjs +0 -72
- package/runtime/scripts/work/draw/rationale-findings.mjs +0 -205
- package/runtime/scripts/work/draw-grammar.mjs +0 -175
- package/runtime/scripts/work/draw-render-page.mjs +0 -188
- package/runtime/scripts/work/draw-render.mjs +0 -626
- package/runtime/scripts/work/png.mjs +0 -237
- package/runtime/scripts/work/render-tools.mjs +0 -64
|
@@ -1,523 +0,0 @@
|
|
|
1
|
-
// trust.mjs — prepare provider trust and guard settings for the launch directory.
|
|
2
|
-
//
|
|
3
|
-
// Automatic launch trust requires this installation's explicit launchTrust owner
|
|
4
|
-
// profile and an exact approved repository root. Missing or declined adoption
|
|
5
|
-
// refuses the launch. Existing provider declines are preserved.
|
|
6
|
-
//
|
|
7
|
-
// ensureLaunchTrust({ agent, cwd })
|
|
8
|
-
// SCOPE (lead ruling 2026-10-01): the command guard hook and every launch setting of Claude and Devin are written
|
|
9
|
-
// to the launch worktree's PROJECT files (projectTargets), never to a user-global settings file, so they reach only
|
|
10
|
-
// the agents the runtime starts in that worktree; each such file is kept out of git status through the repository's
|
|
11
|
-
// own info/exclude (excludeFromGit). The per-user records are the ones a host keeps nowhere else: its trust of the
|
|
12
|
-
// directory, and Codex's guard hook with its trusted hash (the guard acts only for a terminal bound to a guard).
|
|
13
|
-
// claude → ~/.claude.json projects[<cwd>].hasTrustDialogAccepted = true, in
|
|
14
|
-
// every key form Claude writes (win32: `<drive>:/…` and `<drive>:\…`); in
|
|
15
|
-
// <cwd>/.claude/settings.local.json: skipDangerousModePermissionPrompt
|
|
16
|
-
// (set only when missing), each agents/claude.yaml launchEnv key under
|
|
17
|
-
// env (DISABLE_AUTOUPDATER: Orca composes a worker's command, so its
|
|
18
|
-
// launch env cannot carry it) and the guard hook.
|
|
19
|
-
// codex → [projects."<path>"] trust_level = "trusted" in each Codex home the worker can start in
|
|
20
|
-
// (CODEX_HOME, else Orca's codex-runtime-home and the system ~/.codex) for the launch
|
|
21
|
-
// cwd and the git root Codex keys trust by, in the key forms Codex
|
|
22
|
-
// writes (win32: a '<drive>:\lower\case' literal and a "<DRIVE>:\\exact" basic), plus the
|
|
23
|
-
// update-check and model-nudge notices; the guard hook as a block in
|
|
24
|
-
// each home's own config.toml (Codex 0.160.0 lists a project
|
|
25
|
-
// layer's hooks only for a repository's main checkout, never for a linked
|
|
26
|
-
// worktree) and the hash Codex trusts it by (codex app-server hooks/list,
|
|
27
|
-
// then config/batchWrite hooks.state - the way Orca trusts its own hooks).
|
|
28
|
-
// devin → <cwd>/.devin/config.local.json (Devin's local project config): the
|
|
29
|
-
// guard hook only: Devin has no per-worker model pin through Orca.
|
|
30
|
-
// Returns the receipt the launch event records:
|
|
31
|
-
// {agent, paths, status: written|already|skipped|failed, written[], already[], …}
|
|
32
|
-
//
|
|
33
|
-
// Both files are rewritten by running agent sessions, so every write is a
|
|
34
|
-
// read-modify-write with an atomic rename, re-read and verified; a lost update
|
|
35
|
-
// is retried a bounded number of times. Only the named project keys change —
|
|
36
|
-
// every other field/table (and TOML comments) is kept. A test process
|
|
37
|
-
// (NODE_TEST_CONTEXT) never touches the real files: it writes only under
|
|
38
|
-
// STARCI_AGENT_TRUST_HOME, or skips.
|
|
39
|
-
import fs from 'node:fs';
|
|
40
|
-
import os from 'node:os';
|
|
41
|
-
import path from 'node:path';
|
|
42
|
-
import { fileURLToPath } from 'node:url';
|
|
43
|
-
import { parseYaml } from '../../engine/yaml.mjs';
|
|
44
|
-
import { lsFiles } from '../api/git/ls-files.mjs'; import { revParseQuery } from '../api/git/rev-parse-query.mjs';
|
|
45
|
-
import { renameOver } from '../api/fs/rename-over.mjs';
|
|
46
|
-
import { sleepSync } from '../lib/sleep-sync.mjs';
|
|
47
|
-
import { parseJson } from '../lib/json.mjs';
|
|
48
|
-
import { isSpecRun } from '../lib/env.mjs';
|
|
49
|
-
import { isGuardCommand } from '../lib/guard-command.mjs';
|
|
50
|
-
import { HOOK_INPUT_LIMITS } from '../lib/hook-limits.mjs';
|
|
51
|
-
import { orcaUserData } from './host-agents.mjs';
|
|
52
|
-
export { codexTrustPaths, launchTrustVerdict } from './launch-trust-policy.mjs';
|
|
53
|
-
import { codexAppServer } from './codex-app-server.mjs';
|
|
54
|
-
export { codexAppServer };
|
|
55
|
-
|
|
56
|
-
const CLAUDE_CARD = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..', 'modules', 'models', 'agents', 'claude.yaml');
|
|
57
|
-
|
|
58
|
-
const ATTEMPTS = 5;
|
|
59
|
-
|
|
60
|
-
/* ---------------------------------------------------------------- targets */
|
|
61
|
-
|
|
62
|
-
/** Orca's CODEX_HOME: <Electron userData>/codex-runtime-home/home, resolved the way Orca does. */
|
|
63
|
-
export function orcaCodexHome({ env = process.env, platform = process.platform, home = os.homedir() } = {}) {
|
|
64
|
-
if (env.STARCI_ORCA_CODEX_HOME) return env.STARCI_ORCA_CODEX_HOME;
|
|
65
|
-
return path.join(orcaUserData({ env, platform, home }), 'codex-runtime-home', 'home');
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
/**
|
|
69
|
-
* The files trust is written to. STARCI_AGENT_TRUST_HOME re-roots every one of
|
|
70
|
-
* them (specs); otherwise Claude's own CLAUDE_CONFIG_DIR and Codex's CODEX_HOME
|
|
71
|
-
* are honoured. Returns {claudeJson, codexHomes[], home, skipped?}.
|
|
72
|
-
*/
|
|
73
|
-
export function trustTargets({ env = process.env, platform = process.platform } = {}) {
|
|
74
|
-
const root = env.STARCI_AGENT_TRUST_HOME || null;
|
|
75
|
-
if (!root && isSpecRun(env))
|
|
76
|
-
return { skipped: 'a test process writes agent trust only under STARCI_AGENT_TRUST_HOME' };
|
|
77
|
-
const home = root || os.homedir();
|
|
78
|
-
const claudeDir = !root && env.CLAUDE_CONFIG_DIR ? env.CLAUDE_CONFIG_DIR : null;
|
|
79
|
-
const codexHomes = [];
|
|
80
|
-
const addHome = (kind, dir) => {
|
|
81
|
-
if (!dir) return;
|
|
82
|
-
const key = path.resolve(dir).toLowerCase();
|
|
83
|
-
if (!codexHomes.some((h) => path.resolve(h.dir).toLowerCase() === key)) codexHomes.push({ kind, dir: path.resolve(dir) });
|
|
84
|
-
};
|
|
85
|
-
// The active worker home; the system home joins it in codexLaunchHomes (codex-mirror-source.mjs).
|
|
86
|
-
addHome('active-codex-home', root ? path.join(root,'.codex') : (env.CODEX_HOME || orcaCodexHome({ env, platform, home })));
|
|
87
|
-
return {
|
|
88
|
-
claudeJson: claudeDir ? path.join(claudeDir, '.claude.json') : path.join(home, '.claude.json'),
|
|
89
|
-
codexHomes, home,
|
|
90
|
-
};
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
/**
|
|
94
|
-
* The PROJECT-scoped files of the launch directory `dir` (never a user-global file): Claude's local project settings
|
|
95
|
-
* and Devin's local project config. They carry the command guard hook and each host's launch settings, so they
|
|
96
|
-
* apply only to agents started in that worktree.
|
|
97
|
-
*/
|
|
98
|
-
export const projectTargets = (dir) => ({
|
|
99
|
-
claudeSettings: path.join(dir, '.claude', 'settings.local.json'),
|
|
100
|
-
devinConfig: path.join(dir, '.devin', 'config.local.json'),
|
|
101
|
-
});
|
|
102
|
-
|
|
103
|
-
/**
|
|
104
|
-
* Keep a project file the runtime wrote out of `git status`: its path (relative to the checkout's top level) goes in
|
|
105
|
-
* the repository's own info/exclude (`git rev-parse --git-path info/exclude`), never a user-global ignore. A directory
|
|
106
|
-
* that is no git checkout, or a file git already tracks, is left alone. Returns {file, state} or null.
|
|
107
|
-
*/
|
|
108
|
-
export function excludeFromGit(dir, file) {
|
|
109
|
-
const topOut = revParseQuery(['--show-toplevel'], { cwd: dir });
|
|
110
|
-
const excludeOut = revParseQuery(['--path-format=absolute', '--git-path', 'info/exclude'], { cwd: dir });
|
|
111
|
-
if (topOut?.status !== 0 || excludeOut?.status !== 0) return null;
|
|
112
|
-
const top = String(topOut.stdout).trim();
|
|
113
|
-
const rel = path.relative(top, file).split(path.sep).join('/');
|
|
114
|
-
if (!rel || rel.startsWith('..')) return null;
|
|
115
|
-
if (lsFiles(['--error-unmatch', '--', rel], { cwd: top })?.status === 0) return { file: rel, state: 'tracked' };
|
|
116
|
-
const target = String(excludeOut.stdout).trim();
|
|
117
|
-
const line = '/' + rel;
|
|
118
|
-
let text = '';
|
|
119
|
-
try { text = fs.readFileSync(target, 'utf8'); } catch { text = ''; }
|
|
120
|
-
if (text.split(/\r?\n/).includes(line)) return { file: rel, state: 'already' };
|
|
121
|
-
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
122
|
-
fs.appendFileSync(target, (text && !text.endsWith('\n') ? '\n' : '') + line + '\n');
|
|
123
|
-
return { file: rel, state: 'written' };
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
/* -------------------------------------------------------------- key forms */
|
|
127
|
-
|
|
128
|
-
const upperDrive = (p) => p.replace(/^([a-z]):/, (m, d) => `${d.toUpperCase()}:`);
|
|
129
|
-
|
|
130
|
-
/** Claude's project keys for `dir`: win32 writes both `<drive>:/…` and `<drive>:\…`. */
|
|
131
|
-
export function claudeKeyForms(dir, platform = process.platform) {
|
|
132
|
-
if (platform !== 'win32') return [path.posix.resolve(String(dir))];
|
|
133
|
-
const abs = upperDrive(path.win32.resolve(String(dir)));
|
|
134
|
-
return [...new Set([abs.replaceAll('\\', '/'), abs.replaceAll('/', '\\')])];
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
/** Codex's project keys for `dir`: win32 writes '<drive>:\lower' (its own form) and "<DRIVE>:\\exact". */
|
|
138
|
-
export function codexKeyForms(dir, platform = process.platform) {
|
|
139
|
-
if (platform !== 'win32') return [path.posix.resolve(String(dir))];
|
|
140
|
-
const abs = upperDrive(path.win32.resolve(String(dir))).replaceAll('/', '\\');
|
|
141
|
-
return [...new Set([abs.toLowerCase(), abs])];
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
/* ------------------------------------------------------ atomic rewriting */
|
|
145
|
-
|
|
146
|
-
// A transform that wants its input as text: an absent file reads as the empty string.
|
|
147
|
-
const asText = (transform) => (text) => transform(text ?? '');
|
|
148
|
-
export const readText = (file) => { try { return fs.readFileSync(file, 'utf8'); } catch (e) { if (e.code === 'ENOENT') { return null; } throw e; } };
|
|
149
|
-
|
|
150
|
-
/**
|
|
151
|
-
* Read-modify-write `file` with an atomic rename, then re-read and verify.
|
|
152
|
-
* transform(text|null) → {text: next|null (no change needed), result}
|
|
153
|
-
* verify(text) → true when the change is on disk
|
|
154
|
-
* A concurrent rewrite between read and rename, or one that lands after the
|
|
155
|
-
* rename and drops the change, is a lost update: retried up to `attempts`.
|
|
156
|
-
* hooks.beforeRename / hooks.afterRename let specs simulate that writer.
|
|
157
|
-
*/
|
|
158
|
-
function atomicUpdate(file, transform, verify, { attempts = ATTEMPTS, hooks = {} } = {}) {
|
|
159
|
-
const trail = [];
|
|
160
|
-
for (let attempt = 1; attempt <= attempts; attempt += 1) {
|
|
161
|
-
const before = readText(file);
|
|
162
|
-
const { text: next, result } = transform(before);
|
|
163
|
-
if (next == null) {
|
|
164
|
-
if (verify(before)) return { ok: true, changed: false, attempts: attempt, trail, result };
|
|
165
|
-
return { ok: false, error: 'transform produced no change but verification failed', attempts: attempt, trail };
|
|
166
|
-
}
|
|
167
|
-
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
168
|
-
const tmp = `${file}.starci-trust-${process.pid}-${Date.now()}-${attempt}.tmp`;
|
|
169
|
-
fs.writeFileSync(tmp, next);
|
|
170
|
-
try {
|
|
171
|
-
hooks.beforeRename?.({ attempt, file });
|
|
172
|
-
if (readText(file) !== before) { trail.push({ attempt, lost: 'rewritten-before-rename' }); continue; }
|
|
173
|
-
renameOver(tmp, file, { delayMs: (i) => 25 * (i + 1) });
|
|
174
|
-
} finally { try { fs.rmSync(tmp, { force: true }); } catch { /* best-effort */ } }
|
|
175
|
-
hooks.afterRename?.({ attempt, file });
|
|
176
|
-
const onDisk = readText(file);
|
|
177
|
-
if (onDisk != null && verify(onDisk)) return { ok: true, changed: true, attempts: attempt, trail, result };
|
|
178
|
-
trail.push({ attempt, lost: 'overwritten-after-rename' });
|
|
179
|
-
sleepSync(20 * attempt);
|
|
180
|
-
}
|
|
181
|
-
return { ok: false, error: `lost update: ${file} was rewritten on each of ${attempts} attempts`, attempts, trail };
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
/* ------------------------------------------------------------------ claude */
|
|
185
|
-
|
|
186
|
-
// The project shape Claude itself writes for a new directory.
|
|
187
|
-
const CLAUDE_PROJECT_DEFAULT = () => ({
|
|
188
|
-
allowedTools: [], mcpContextUris: [], mcpServers: {}, enabledMcpjsonServers: [], disabledMcpjsonServers: [],
|
|
189
|
-
hasTrustDialogAccepted: true, hasClaudeMdExternalIncludesApproved: false, hasClaudeMdExternalIncludesWarningShown: false,
|
|
190
|
-
});
|
|
191
|
-
|
|
192
|
-
// JSON.parse → stringify is exact for every value except integers past 2^53.
|
|
193
|
-
// Refuse to rewrite a file whose long number literals would not survive.
|
|
194
|
-
const longNumbers = (text) => (text.match(/(?<![\w."])-?\d{16,}(?:\.\d+)?(?:[eE][+-]?\d+)?(?![\w"])/g) ?? []);
|
|
195
|
-
const numbersSurvive = (before, after) => longNumbers(before).every((n) => after.includes(n));
|
|
196
|
-
|
|
197
|
-
export const jsonOf = (text) => parseJson(text, undefined);
|
|
198
|
-
|
|
199
|
-
/** Set projects[key].hasTrustDialogAccepted = true for every key in `file` (~/.claude.json). */
|
|
200
|
-
export function writeClaudeTrust({ file, keys, hooks }) {
|
|
201
|
-
const verify = (text) => {
|
|
202
|
-
const doc = text == null ? null : jsonOf(text);
|
|
203
|
-
return !!doc && keys.every((k) => doc.projects?.[k]?.hasTrustDialogAccepted === true);
|
|
204
|
-
};
|
|
205
|
-
const out = { file, written: [], already: [] };
|
|
206
|
-
const updated = atomicUpdate(file, (text) => {
|
|
207
|
-
const doc = text == null ? {} : jsonOf(text);
|
|
208
|
-
if (doc === undefined || !doc || typeof doc !== 'object' || Array.isArray(doc))
|
|
209
|
-
throw new Error(`${file} is not a JSON object; refusing to rewrite it`);
|
|
210
|
-
const written = [], already = [];
|
|
211
|
-
if (!doc.projects || typeof doc.projects !== 'object') doc.projects = {};
|
|
212
|
-
for (const key of keys) {
|
|
213
|
-
const existing = doc.projects[key];
|
|
214
|
-
if (existing?.hasTrustDialogAccepted === true) { already.push(key); continue; }
|
|
215
|
-
if (existing?.hasTrustDialogAccepted === false) throw new Error(`${file}: owner declined trust for ${key}`);
|
|
216
|
-
doc.projects[key] = existing && typeof existing === 'object' ? { ...existing, hasTrustDialogAccepted: true } : CLAUDE_PROJECT_DEFAULT();
|
|
217
|
-
written.push(key);
|
|
218
|
-
}
|
|
219
|
-
if (!written.length) return { text: null, result: { written, already } };
|
|
220
|
-
const next = JSON.stringify(doc, null, 2) + (text?.endsWith('\n') ? '\n' : '');
|
|
221
|
-
if (text && !numbersSurvive(text, next)) throw new Error(`${file} holds a number JSON cannot round-trip; refusing to rewrite it`);
|
|
222
|
-
return { text: next, result: { written, already } };
|
|
223
|
-
}, verify, { hooks });
|
|
224
|
-
if (!updated.ok) return { ...out, ok: false, error: updated.error, trail: updated.trail };
|
|
225
|
-
out.written = updated.result.written;
|
|
226
|
-
out.already = updated.result.already;
|
|
227
|
-
return { ...out, ok: true, attempts: updated.attempts, ...(updated.trail.length ? { trail: updated.trail } : {}) };
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
/** Assert skipDangerousModePermissionPrompt in Claude's settings.json; set it only when missing. */
|
|
231
|
-
export function assertClaudeBypassConsent({ file, hooks }) {
|
|
232
|
-
const current = readText(file);
|
|
233
|
-
const doc = current == null ? {} : jsonOf(current);
|
|
234
|
-
if (doc === undefined || !doc || typeof doc !== 'object') return { file, ok: false, state: 'unreadable' };
|
|
235
|
-
if (doc.skipDangerousModePermissionPrompt === true) return { file, ok: true, state: 'already' };
|
|
236
|
-
if ('skipDangerousModePermissionPrompt' in doc) return { file, ok: false, state: 'owner-set-false' };
|
|
237
|
-
const updated = atomicUpdate(file, (text) => {
|
|
238
|
-
const d = text == null ? {} : jsonOf(text);
|
|
239
|
-
if (d === undefined || !d || typeof d !== 'object') throw new Error(`${file} is not a JSON object`);
|
|
240
|
-
if ('skipDangerousModePermissionPrompt' in d) return { text: null, result: null };
|
|
241
|
-
d.skipDangerousModePermissionPrompt = true;
|
|
242
|
-
return { text: JSON.stringify(d, null, 2) + (text?.endsWith('\n') ? '\n' : ''), result: null };
|
|
243
|
-
}, (text) => jsonOf(text ?? '')?.skipDangerousModePermissionPrompt === true, { hooks });
|
|
244
|
-
return updated.ok ? { file, ok: true, state: updated.changed ? 'written' : 'already' } : { file, ok: false, state: 'failed', error: updated.error };
|
|
245
|
-
}
|
|
246
|
-
|
|
247
|
-
/** agents/claude.yaml launchEnv: {KEY: 'value'} (string values only), or {} when the card has none. */
|
|
248
|
-
export function claudeLaunchEnv({ file = CLAUDE_CARD } = {}) {
|
|
249
|
-
try {
|
|
250
|
-
const vars = parseYaml(fs.readFileSync(file, 'utf8'))?.launchEnv;
|
|
251
|
-
if (!vars || typeof vars !== 'object' || Array.isArray(vars)) return {};
|
|
252
|
-
return Object.fromEntries(Object.entries(vars).filter(([k, v]) => /^[A-Z_][A-Z0-9_]*$/.test(k) && v != null).map(([k, v]) => [k, String(v)]));
|
|
253
|
-
} catch { return {}; }
|
|
254
|
-
}
|
|
255
|
-
|
|
256
|
-
/**
|
|
257
|
-
* Assert each `vars` key under settings.json env; set only the keys that are missing (an owner-set value, even a
|
|
258
|
-
* different one, is kept). Returns {file, ok, state: written|already|owner-set|unreadable|failed, owner?: [keys]}.
|
|
259
|
-
*/
|
|
260
|
-
export function assertClaudeSettingsEnv({ file, vars, hooks }) {
|
|
261
|
-
const keys = Object.keys(vars ?? {});
|
|
262
|
-
if (!keys.length) return { file, ok: true, state: 'already' };
|
|
263
|
-
const current = readText(file);
|
|
264
|
-
const doc = current == null ? {} : jsonOf(current);
|
|
265
|
-
if (doc === undefined || !doc || typeof doc !== 'object' || Array.isArray(doc)) return { file, ok: false, state: 'unreadable' };
|
|
266
|
-
if (doc.env != null && (typeof doc.env !== 'object' || Array.isArray(doc.env))) return { file, ok: false, state: 'unreadable' };
|
|
267
|
-
const has = (d, k) => d.env && typeof d.env === 'object' && Object.hasOwn(d.env, k);
|
|
268
|
-
const owner = keys.filter((k) => has(doc, k) && String(doc.env[k]) !== vars[k]);
|
|
269
|
-
const missing = keys.filter((k) => !has(doc, k));
|
|
270
|
-
if (!missing.length) return { file, ok: true, state: owner.length ? 'owner-set' : 'already', ...(owner.length ? { owner } : {}) };
|
|
271
|
-
const updated = atomicUpdate(file, (text) => {
|
|
272
|
-
const d = text == null ? {} : jsonOf(text);
|
|
273
|
-
if (d === undefined || !d || typeof d !== 'object' || Array.isArray(d)) throw new Error(`${file} is not a JSON object`);
|
|
274
|
-
const add = keys.filter((k) => !has(d, k));
|
|
275
|
-
if (!add.length) return { text: null, result: null };
|
|
276
|
-
d.env = { ...(d.env && typeof d.env === 'object' ? d.env : {}), ...Object.fromEntries(add.map((k) => [k, vars[k]])) };
|
|
277
|
-
return { text: JSON.stringify(d, null, 2) + (text?.endsWith('\n') ? '\n' : ''), result: null };
|
|
278
|
-
}, (text) => { const d = jsonOf(text ?? ''); return !!d && keys.every((k) => has(d, k)); }, { hooks });
|
|
279
|
-
return updated.ok ? { file, ok: true, state: updated.changed ? 'written' : 'already', ...(owner.length ? { owner } : {}) }
|
|
280
|
-
: { file, ok: false, state: 'failed', error: updated.error };
|
|
281
|
-
}
|
|
282
|
-
|
|
283
|
-
/* ------------------------------------------------------------------- codex */
|
|
284
|
-
|
|
285
|
-
// A TOML key segment: bare, 'literal' or "basic" (with escapes).
|
|
286
|
-
const KEY_SEG = String.raw`(?:[A-Za-z0-9_-]+|'[^'\r\n]*'|"(?:[^"\\\r\n]|\\.)*")`;
|
|
287
|
-
const PROJECT_HEADER = new RegExp(String.raw`^[ \t]*\[[ \t]*projects[ \t]*\.[ \t]*(${KEY_SEG})[ \t]*\][ \t]*(?:#.*)?$`);
|
|
288
|
-
const ANY_HEADER = /^[ \t]*\[/;
|
|
289
|
-
const TRUST_LINE = /^([ \t]*trust_level[ \t]*=[ \t]*)("(?:[^"\\]|\\.)*"|'[^']*')(.*)$/;
|
|
290
|
-
|
|
291
|
-
const decodeKey = (seg) => {
|
|
292
|
-
if (seg.startsWith("'")) return seg.slice(1, -1);
|
|
293
|
-
if (!seg.startsWith('"')) return seg;
|
|
294
|
-
return seg.slice(1, -1).replace(/\\(u[0-9a-fA-F]{4}|U[0-9a-fA-F]{8}|.)/g, (m, e) => {
|
|
295
|
-
if (e[0] === 'u' || e[0] === 'U') return String.fromCodePoint(Number.parseInt(e.slice(1), 16));
|
|
296
|
-
return { b: '\b', t: '\t', n: '\n', f: '\f', r: '\r', '"': '"', '\\': '\\' }[e] ?? e;
|
|
297
|
-
});
|
|
298
|
-
};
|
|
299
|
-
const decodeValue = (v) => (v.startsWith("'") ? v.slice(1, -1) : decodeKey(v));
|
|
300
|
-
|
|
301
|
-
const ESCAPED_QUOTE = String.raw`\"`;
|
|
302
|
-
/** A TOML basic-string body: backslash and double quote escaped. */
|
|
303
|
-
const tomlEscaped = (v) => String(v).replaceAll('\\', '\\\\').replaceAll('"', ESCAPED_QUOTE);
|
|
304
|
-
|
|
305
|
-
/** The header Codex would write for `key`: a literal for its lowercase form, else a basic string. */
|
|
306
|
-
function codexHeader(key) {
|
|
307
|
-
const literal = !/['\r\n]/.test(key) && key === key.toLowerCase() && key.includes('\\');
|
|
308
|
-
return literal ? `[projects.'${key}']` : `[projects."${tomlEscaped(key)}"]`;
|
|
309
|
-
}
|
|
310
|
-
|
|
311
|
-
/** {key → {header line index, trust line index|null, trust value|null, end}} for every [projects.<key>] table. */
|
|
312
|
-
export function codexProjectTables(text) {
|
|
313
|
-
const lines = String(text ?? '').split('\n');
|
|
314
|
-
const tables = new Map();
|
|
315
|
-
let current = null;
|
|
316
|
-
lines.forEach((line, i) => {
|
|
317
|
-
const bare = line.replace(/\r$/, '');
|
|
318
|
-
const m = PROJECT_HEADER.exec(bare);
|
|
319
|
-
if (m) { current = { key: decodeKey(m[1]), header: i, trustLine: null, trust: null, end: i }; if (!tables.has(current.key)) { tables.set(current.key, current); } return; }
|
|
320
|
-
if (ANY_HEADER.test(bare)) { current = null; return; }
|
|
321
|
-
if (!current) return;
|
|
322
|
-
if (bare.trim()) current.end = i;
|
|
323
|
-
const t = TRUST_LINE.exec(bare);
|
|
324
|
-
if (t && current.trustLine === null) { current.trustLine = i; current.trust = decodeValue(t[2]); }
|
|
325
|
-
});
|
|
326
|
-
return tables;
|
|
327
|
-
}
|
|
328
|
-
|
|
329
|
-
const ownerTrustSet = (table) => table?.trust !== null && table?.trust !== undefined;
|
|
330
|
-
|
|
331
|
-
function trustExistingLine(lines, index) {
|
|
332
|
-
const cr = lines[index].endsWith('\r') ? '\r' : '';
|
|
333
|
-
const m = TRUST_LINE.exec(lines[index].replace(/\r$/, ''));
|
|
334
|
-
lines[index] = `${m[1]}"trusted"${m[3]}${cr}`;
|
|
335
|
-
}
|
|
336
|
-
|
|
337
|
-
function appendTrustTables(text, keys, eol) {
|
|
338
|
-
let next = text;
|
|
339
|
-
if (keys.length && next && !next.endsWith('\n')) next += eol;
|
|
340
|
-
for (const key of keys) next += `${next ? eol : ''}${codexHeader(key)}${eol}trust_level = "trusted"${eol}`;
|
|
341
|
-
return next;
|
|
342
|
-
}
|
|
343
|
-
|
|
344
|
-
/** The edit that trusts every key of a Codex config.toml: `{ text: next|null, result: { written, already } }`. */
|
|
345
|
-
function codexTrustEdit(source, keys, file) {
|
|
346
|
-
const eol = source.includes('\r\n') ? '\r\n' : '\n';
|
|
347
|
-
const tables = codexProjectTables(source);
|
|
348
|
-
const lines = source.split('\n');
|
|
349
|
-
const written = [], already = [], append = [];
|
|
350
|
-
const inserts = [];
|
|
351
|
-
for (const key of keys) {
|
|
352
|
-
const table = tables.get(key);
|
|
353
|
-
if (table?.trust === 'trusted') { already.push(key); continue; }
|
|
354
|
-
if (ownerTrustSet(table)) throw new Error(`${file}: owner set ${key} trust_level=${table.trust}; refusing to override`);
|
|
355
|
-
written.push(key);
|
|
356
|
-
if (!table) append.push(key);
|
|
357
|
-
else if (table.trustLine === null) inserts.push(table.header);
|
|
358
|
-
else trustExistingLine(lines, table.trustLine);
|
|
359
|
-
}
|
|
360
|
-
if (!written.length) return { text: null, result: { written, already } };
|
|
361
|
-
for (const at of inserts.toSorted((a, b) => b - a)) lines.splice(at + 1, 0, `trust_level = "trusted"${eol === '\r\n' ? '\r' : ''}`);
|
|
362
|
-
return { text: appendTrustTables(lines.join('\n'), append, eol), result: { written, already } };
|
|
363
|
-
}
|
|
364
|
-
|
|
365
|
-
/** Ensure trust_level = "trusted" for every key in a Codex config.toml. Appends; edits only an untrusted table's own line. */
|
|
366
|
-
export function writeCodexTrust({ file, keys, hooks }) {
|
|
367
|
-
const verify = (text) => {
|
|
368
|
-
const tables = codexProjectTables(text ?? '');
|
|
369
|
-
return keys.every((k) => tables.get(k)?.trust === 'trusted');
|
|
370
|
-
};
|
|
371
|
-
const out = { file, written: [], already: [] };
|
|
372
|
-
const updated = atomicUpdate(file, asText((source) => codexTrustEdit(source, keys, file)), verify, { hooks });
|
|
373
|
-
if (!updated.ok) return { ...out, ok: false, error: updated.error, trail: updated.trail };
|
|
374
|
-
out.written = updated.result.written;
|
|
375
|
-
out.already = updated.result.already;
|
|
376
|
-
return { ...out, ok: true, attempts: updated.attempts, ...(updated.trail.length ? { trail: updated.trail } : {}) };
|
|
377
|
-
}
|
|
378
|
-
|
|
379
|
-
// A new Codex release turned every fresh Codex launch into an interactive
|
|
380
|
-
// "Update available! … Press enter to continue" menu: eighteen op launches in
|
|
381
|
-
// three workflows failed readiness or model attestation in three hours while
|
|
382
|
-
// the kernels, started before the release, kept working. The launch must
|
|
383
|
-
// never wait on it, so the top-level check_for_update_on_startup is pinned
|
|
384
|
-
// false in every Codex home, like trust.
|
|
385
|
-
const UPDATE_CHECK_LINE = /^[ \t]*check_for_update_on_startup[ \t]*=.*$/m;
|
|
386
|
-
const topLevelOf = (text) => { const at = String(text ?? '').search(/^[ \t]*\[/m); return at < 0 ? String(text ?? '') : String(text ?? '').slice(0, at); };
|
|
387
|
-
export function writeCodexNoUpdateCheck({ file, hooks }) {
|
|
388
|
-
const verify = (text) => /^[ \t]*check_for_update_on_startup[ \t]*=[ \t]*false[ \t]*(?:#.*)?\r?$/m.test(topLevelOf(text));
|
|
389
|
-
const updated = atomicUpdate(file, asText((source) => {
|
|
390
|
-
if (verify(source)) return { text: null, result: { written: false } };
|
|
391
|
-
const eol = source.includes('\r\n') ? '\r\n' : '\n';
|
|
392
|
-
const top = topLevelOf(source);
|
|
393
|
-
// An existing key is flipped in place; a new one closes the top-level block,
|
|
394
|
-
// so the owner's own leading keys stay where they were.
|
|
395
|
-
const head = top && !top.endsWith('\n') ? `${top}${eol}` : top;
|
|
396
|
-
const next = UPDATE_CHECK_LINE.test(top)
|
|
397
|
-
? top.replace(UPDATE_CHECK_LINE, 'check_for_update_on_startup = false') + source.slice(top.length)
|
|
398
|
-
: `${head}check_for_update_on_startup = false${eol}${source.slice(top.length)}`;
|
|
399
|
-
return { text: next, result: { written: true } };
|
|
400
|
-
}), verify, { hooks });
|
|
401
|
-
if (!updated.ok) return { file, ok: false, error: updated.error };
|
|
402
|
-
return { file, ok: true, written: updated.result.written };
|
|
403
|
-
}
|
|
404
|
-
|
|
405
|
-
// Codex's quota nudge ("Approaching rate limits - Switch to <cheaper model>?") stopped a Collab interface.draw op
|
|
406
|
-
// at its menu (2026-09-24). Owner: always keep the current model and never show it again - the answer Codex
|
|
407
|
-
// itself records as [notice] hide_rate_limit_model_nudge = true, pinned in every Codex home.
|
|
408
|
-
const NUDGE_LINE = /^[ \t]*hide_rate_limit_model_nudge[ \t]*=.*$/m;
|
|
409
|
-
const noticeTable = (text) => {
|
|
410
|
-
const at = text.search(/^[ \t]*\[notice\][ \t]*(?:#.*)?\r?$/m);
|
|
411
|
-
if (at < 0) return null;
|
|
412
|
-
const bodyAt = text.indexOf('\n', at) + 1 || text.length;
|
|
413
|
-
const next = text.slice(bodyAt).search(/^[ \t]*\[/m);
|
|
414
|
-
return { bodyAt, end: next < 0 ? text.length : bodyAt + next };
|
|
415
|
-
};
|
|
416
|
-
export function writeCodexNoModelNudge({ file, hooks }) {
|
|
417
|
-
const verify = (text) => {
|
|
418
|
-
const table = noticeTable(String(text ?? ''));
|
|
419
|
-
return Boolean(table) && /^[ \t]*hide_rate_limit_model_nudge[ \t]*=[ \t]*true[ \t]*(?:#.*)?\r?$/m.test(String(text).slice(table.bodyAt, table.end));
|
|
420
|
-
};
|
|
421
|
-
const updated = atomicUpdate(file, asText((source) => {
|
|
422
|
-
if (verify(source)) return { text: null, result: { written: false } };
|
|
423
|
-
const eol = source.includes('\r\n') ? '\r\n' : '\n';
|
|
424
|
-
const table = noticeTable(source);
|
|
425
|
-
let next;
|
|
426
|
-
if (table) {
|
|
427
|
-
const body = source.slice(table.bodyAt, table.end);
|
|
428
|
-
const fixed = NUDGE_LINE.test(body) ? body.replace(NUDGE_LINE, 'hide_rate_limit_model_nudge = true') : `hide_rate_limit_model_nudge = true${eol}${body}`;
|
|
429
|
-
next = source.slice(0, table.bodyAt) + fixed + source.slice(table.end);
|
|
430
|
-
} else {
|
|
431
|
-
const base = source && !source.endsWith('\n') ? `${source}${eol}` : source;
|
|
432
|
-
next = `${base}${base ? eol : ''}[notice]${eol}hide_rate_limit_model_nudge = true${eol}`;
|
|
433
|
-
}
|
|
434
|
-
return { text: next, result: { written: true } };
|
|
435
|
-
}), verify, { hooks });
|
|
436
|
-
if (!updated.ok) return { file, ok: false, error: updated.error };
|
|
437
|
-
return { file, ok: true, written: updated.result.written };
|
|
438
|
-
}
|
|
439
|
-
|
|
440
|
-
/* ------------------------------------------------------ the command guard */
|
|
441
|
-
|
|
442
|
-
export const TOOL_GUARD_MATCHER = 'Bash|PowerShell|Edit|Write|MultiEdit|NotebookEdit';
|
|
443
|
-
const isGuardHandler = (h) => typeof h?.command === 'string' && (isGuardCommand(h.command) || h.command.includes('command-guard.mjs'));
|
|
444
|
-
const isGuardGroup = (g) => Array.isArray(g?.hooks) && g.hooks.some(isGuardHandler);
|
|
445
|
-
|
|
446
|
-
/**
|
|
447
|
-
* Ensure hooks.PreToolUse of a Claude-format JSON config (Claude's settings.json, Devin's config.json) holds exactly
|
|
448
|
-
* one command-guard group: {matcher?, hooks: [{type: command, command, timeout}]}. An older guard group (another
|
|
449
|
-
* runtime path) is replaced; every other hook is kept.
|
|
450
|
-
*/
|
|
451
|
-
export function assertJsonToolGuard({ file, command, matcher = null, hooks }) {
|
|
452
|
-
const group = { ...(matcher ? { matcher } : {}), hooks: [{ type: 'command', command, timeout: HOOK_INPUT_LIMITS.hookTimeoutSeconds }] };
|
|
453
|
-
const holds = (d) => {
|
|
454
|
-
const list = Array.isArray(d?.hooks?.PreToolUse) ? d.hooks.PreToolUse.filter(isGuardGroup) : [];
|
|
455
|
-
return list.length === 1 && JSON.stringify(list[0]) === JSON.stringify(group);
|
|
456
|
-
};
|
|
457
|
-
let updated;
|
|
458
|
-
try { updated = atomicUpdate(file, (text) => {
|
|
459
|
-
const d = text == null ? {} : jsonOf(text);
|
|
460
|
-
if (d === undefined || !d || typeof d !== 'object' || Array.isArray(d)) throw new Error(`${file} is not a JSON object`);
|
|
461
|
-
if (d.hooks != null && (typeof d.hooks !== 'object' || Array.isArray(d.hooks))) throw new Error(`${file} hooks is not an object`);
|
|
462
|
-
if (holds(d)) return { text: null, result: null };
|
|
463
|
-
d.hooks = { ...d.hooks };
|
|
464
|
-
const others = Array.isArray(d.hooks.PreToolUse) ? d.hooks.PreToolUse.filter((g) => !isGuardGroup(g)) : [];
|
|
465
|
-
d.hooks.PreToolUse = [...others, group];
|
|
466
|
-
const next = JSON.stringify(d, null, 2) + (text?.endsWith('\n') ? '\n' : '');
|
|
467
|
-
if (text && !numbersSurvive(text, next)) throw new Error(`${file} holds a number JSON cannot round-trip; refusing to rewrite it`);
|
|
468
|
-
return { text: next, result: null };
|
|
469
|
-
}, (text) => { const d = jsonOf(text ?? ''); return !!d && holds(d); }, { hooks }); }
|
|
470
|
-
catch (e) { return { file, ok: false, state: 'failed', error: String(e?.message ?? e) }; }
|
|
471
|
-
return updated.ok ? { file, ok: true, state: updated.changed ? 'written' : 'already' } : { file, ok: false, state: 'failed', error: updated.error };
|
|
472
|
-
}
|
|
473
|
-
|
|
474
|
-
/** Devin's project config: the command guard only. LIVE E7 (2026-10-02): Devin honours agent.model only in the user config (--config) or DEVIN_MODEL, never in <project>/.devin/config*.json, and Orca refuses a launch-time model for Devin, so no model is written here (an external limitation, see the CHANGELOG). */
|
|
475
|
-
export function writeDevinProfile({ file, command, hooks }) {
|
|
476
|
-
return assertJsonToolGuard({ file, command, hooks });
|
|
477
|
-
}
|
|
478
|
-
|
|
479
|
-
// A Codex hook lives in the home's config.toml as an array-of-tables block the runtime owns, marked on its first line.
|
|
480
|
-
const CODEX_GUARD_BEGIN = '# starci-command-guard (scripts/agent/trust.mjs): the op command guard, a PreToolUse hook';
|
|
481
|
-
export function codexGuardBlock(command, eol = '\n') {
|
|
482
|
-
const q = (v) => `"${tomlEscaped(v)}"`;
|
|
483
|
-
return [CODEX_GUARD_BEGIN, '[[hooks.PreToolUse]]', 'matcher = "^Bash$"', '', '[[hooks.PreToolUse.hooks]]', 'type = "command"', `command = ${q(command)}`, `timeout = ${HOOK_INPUT_LIMITS.hookTimeoutSeconds}`, ''].join(eol);
|
|
484
|
-
}
|
|
485
|
-
/** Put the guard block in a Codex config.toml: appended, or an older block (another runtime path) replaced in place. */
|
|
486
|
-
export function writeCodexToolGuard({ file, command, hooks }) {
|
|
487
|
-
const eolOf = (text) => (String(text ?? '').includes('\r\n') ? '\r\n' : '\n');
|
|
488
|
-
const verify = (text) => String(text ?? '').includes(codexGuardBlock(command, eolOf(text)));
|
|
489
|
-
const updated = atomicUpdate(file, asText((source) => {
|
|
490
|
-
if (verify(source)) return { text: null, result: { written: false } };
|
|
491
|
-
const eol = eolOf(source);
|
|
492
|
-
const at = source.indexOf(CODEX_GUARD_BEGIN);
|
|
493
|
-
if (at >= 0) {
|
|
494
|
-
// The block ends at the first table header that is not its own.
|
|
495
|
-
const rest = source.slice(at);
|
|
496
|
-
const end = /^[ \t]*\[(?!\[hooks\.PreToolUse(?:\.hooks)?\]\])/m.exec(rest);
|
|
497
|
-
return { text: source.slice(0, at) + codexGuardBlock(command, eol) + (end ? eol + rest.slice(end.index) : ''), result: { written: true } };
|
|
498
|
-
}
|
|
499
|
-
const base = source && !source.endsWith('\n') ? `${source}${eol}` : source;
|
|
500
|
-
return { text: `${base}${base ? eol : ''}${codexGuardBlock(command, eol)}`, result: { written: true } };
|
|
501
|
-
}), verify, { hooks });
|
|
502
|
-
return updated.ok ? { file, ok: true, written: updated.result.written } : { file, ok: false, error: updated.error };
|
|
503
|
-
}
|
|
504
|
-
|
|
505
|
-
// The `codex app-server` client lives in codex-app-server.mjs (this file is over its size budget); it is re-exported
|
|
506
|
-
// above so callers of trust.mjs keep their import. A launch that gets no answer throws APP_SERVER_FAILURE with
|
|
507
|
-
// codex's own exit, stderr tail and version - a worker-start strike of the codex circuit (provider-outage.mjs).
|
|
508
|
-
|
|
509
|
-
/** Trust the guard hook of one Codex home: hooks/list, then hooks.state.<key>.trusted_hash for each untrusted one. */
|
|
510
|
-
export function trustCodexToolGuard({ home, cwd, command, appServer = codexAppServer }) {
|
|
511
|
-
const listed = (result) => (result?.data ?? []).flatMap((d) => d?.hooks ?? []).filter((h) => h?.command === command && h?.eventName === 'preToolUse');
|
|
512
|
-
const [before] = appServer({ home, requests: [{ method: 'hooks/list', params: { cwds: [cwd] } }] });
|
|
513
|
-
const entries = listed(before);
|
|
514
|
-
if (!entries.length) return { home, ok: false, error: 'codex hooks/list does not show the command guard' };
|
|
515
|
-
const untrusted = entries.filter((h) => h.trustStatus !== 'trusted');
|
|
516
|
-
if (!untrusted.length) return { home, ok: true, trusted: 'already' };
|
|
517
|
-
const value = Object.fromEntries(untrusted.map((h) => [h.key, { trusted_hash: h.currentHash }]));
|
|
518
|
-
const [, after] = appServer({ home, requests: [
|
|
519
|
-
{ method: 'config/batchWrite', params: { edits: [{ keyPath: 'hooks.state', value, mergeStrategy: 'upsert' }], reloadUserConfig: true } },
|
|
520
|
-
{ method: 'hooks/list', params: { cwds: [cwd] } }] });
|
|
521
|
-
const still = listed(after).filter((h) => h.trustStatus !== 'trusted');
|
|
522
|
-
return still.length ? { home, ok: false, error: `codex still reports the guard hook ${still[0].trustStatus ?? 'untrusted'}` } : { home, ok: true, trusted: 'written' };
|
|
523
|
-
}
|
|
@@ -1,8 +0,0 @@
|
|
|
1
|
-
// temp-path.mjs — a path under the temp root (engine/temp-root.mjs tempRoot, ensured by ensure-temp-root.mjs) whose directory exists: a scratch file or a named subdirectory the caller makes.
|
|
2
|
-
import path from 'node:path';
|
|
3
|
-
import { ensureTempRoot } from './ensure-temp-root.mjs';
|
|
4
|
-
|
|
5
|
-
/** `parts` joined under the temp root, which is created when it is missing (TEMP_ROOT_UNUSABLE when it cannot be made). */
|
|
6
|
-
export function tempPath(...parts) {
|
|
7
|
-
return path.join(ensureTempRoot(), ...parts);
|
|
8
|
-
}
|
|
@@ -1,8 +0,0 @@
|
|
|
1
|
-
// diff-names.mjs — `git diff --name-only <from>..<to> -- <paths>`: the files a commit range changed under `paths`.
|
|
2
|
-
import { gitRunner } from './lib.mjs';
|
|
3
|
-
|
|
4
|
-
/** The changed paths ([] when none), or null when git does not answer. git: the caller's runner or null. */
|
|
5
|
-
export function diffNames(cwd, from, to, { paths = [], git = null } = {}) {
|
|
6
|
-
const r = gitRunner(git)(['diff', '--name-only', `${from}..${to}`, '--', ...paths], { cwd, timeout: 20_000 });
|
|
7
|
-
return r.ok ? r.stdout.split(/\r?\n/).map((l) => l.trim()).filter(Boolean) : null;
|
|
8
|
-
}
|
|
@@ -1,10 +0,0 @@
|
|
|
1
|
-
// head-time.mjs — `git log -1 --format=%ct %h`: the committer time (seconds) and short sha of HEAD, or null.
|
|
2
|
-
import { runGit } from './lib.mjs';
|
|
3
|
-
|
|
4
|
-
/** {time, sha} of HEAD in the repository at `cwd` (time in seconds); null when `cwd` is not a checkout. */
|
|
5
|
-
export const headTime = (cwd) => {
|
|
6
|
-
const r = runGit(['log', '-1', '--format=%ct %h'], { cwd, timeout: 5000 });
|
|
7
|
-
if (r.error || r.status !== 0) return null;
|
|
8
|
-
const [ct, sha] = String(r.stdout ?? '').trim().split(' ');
|
|
9
|
-
return /^\d+$/.test(ct) && sha ? { time: Number(ct), sha } : null;
|
|
10
|
-
};
|
|
@@ -1,78 +0,0 @@
|
|
|
1
|
-
// index-lock.mjs — the ONE recovery of a stale `.git/index.lock` in a shared product checkout.
|
|
2
|
-
//
|
|
3
|
-
// Every workflow of a product repo works in one checkout (one shared checkout per product). A git process that
|
|
4
|
-
// dies mid-write (a host terminal wipe, a killed worker) leaves `<gitdir>/index.lock` behind, and from then on
|
|
5
|
-
// every `git add` / `git commit` in that checkout refuses "Unable to create .../index.lock: File exists" - for
|
|
6
|
-
// every workflow at once. One op once settled blocked on
|
|
7
|
-
// a 403 KB lock left over with no git process alive; the worker rightly would not delete a shared lock.
|
|
8
|
-
//
|
|
9
|
-
// indexLock removes such a lock only when all of these hold:
|
|
10
|
-
// - it is a plain file (never a link), older than allocation.housekeeping.gitIndexLockStaleMs (runtimes.yaml);
|
|
11
|
-
// - the host process table was read (a failed probe removes nothing), and no git process may be working on
|
|
12
|
-
// this repository: a git-family process whose command line names this repo (-C, --git-dir, --work-tree) or
|
|
13
|
-
// names no repository at all (its cwd cannot be read, so it may be this repo's) keeps the lock;
|
|
14
|
-
// - the lock is the same file (mtime and size) after the probe as before it.
|
|
15
|
-
// The caller reads the process table (`list`) and records a removal (`record`): scripts/machine/lock-recovery.mjs composes
|
|
16
|
-
// both, as a `git-index-lock-removed` Supervisor audit event (machine.sqlite sup_events); every other outcome is returned,
|
|
17
|
-
// never thrown. Callers: the command guard before an op worker's git command runs (scripts/guards/command-guard.mjs,
|
|
18
|
-
// through lock-recovery.mjs preflightIndexLock) and the housekeeping area `gitlocks` (scripts/housekeeping/hk-git-locks.mjs).
|
|
19
|
-
import fs from 'node:fs';
|
|
20
|
-
import path from 'node:path';
|
|
21
|
-
import { gitDirOf, lockStat, processOnRepo } from '../../lib/git-dir.mjs';
|
|
22
|
-
|
|
23
|
-
// A lock younger than `staleMs` (or one with no usable threshold) is not stale yet.
|
|
24
|
-
const notStale = (ageMs, staleMs) => {
|
|
25
|
-
const threshold = Number(staleMs);
|
|
26
|
-
return Number.isNaN(threshold) || threshold <= 0 || ageMs < threshold;
|
|
27
|
-
};
|
|
28
|
-
|
|
29
|
-
const initialLockState = (repo, staleMs, now) => {
|
|
30
|
-
const gitDir = gitDirOf(repo);
|
|
31
|
-
const lock = gitDir ? path.join(gitDir, 'index.lock') : null;
|
|
32
|
-
const out = { repo: path.resolve(String(repo ?? '')), lock, state: 'absent' };
|
|
33
|
-
const before = lock ? lockStat(lock) : null;
|
|
34
|
-
if (!before) return { out, before, result: out };
|
|
35
|
-
out.ageMs = Math.max(0, now - before.mtimeMs);
|
|
36
|
-
out.bytes = before.size;
|
|
37
|
-
if (before.isSymbolicLink() || !before.isFile()) return { out, before, result: { ...out, state: 'not-a-file' } };
|
|
38
|
-
if (notStale(out.ageMs, staleMs)) return { out, before, result: { ...out, state: 'fresh' } };
|
|
39
|
-
return { out, before, result: null };
|
|
40
|
-
};
|
|
41
|
-
|
|
42
|
-
const processHoldersOnRepo = (list, repo) => {
|
|
43
|
-
let procs = null;
|
|
44
|
-
try { procs = typeof list === 'function' ? list() : null; } catch { procs = null; }
|
|
45
|
-
return Array.isArray(procs) ? procs.filter((p) => processOnRepo(p, repo) !== 'other') : null;
|
|
46
|
-
};
|
|
47
|
-
|
|
48
|
-
const removeVerifiedLock = ({ lock, out, before, apply, record }) => {
|
|
49
|
-
const after = lockStat(lock);
|
|
50
|
-
if (!after) return { ...out, state: 'absent' };
|
|
51
|
-
if (after.mtimeMs !== before.mtimeMs || after.size !== before.size) return { ...out, state: 'changed' };
|
|
52
|
-
if (!apply) return { ...out, state: 'would-remove' };
|
|
53
|
-
try { fs.unlinkSync(lock); } catch (error) {
|
|
54
|
-
if (error?.code !== 'ENOENT') return { ...out, state: 'error', error: `${error?.code ?? 'ERROR'}: ${error?.message ?? error}` };
|
|
55
|
-
}
|
|
56
|
-
const removed = { ...out, state: 'removed', mtime: new Date(before.mtimeMs).toISOString() };
|
|
57
|
-
if (typeof record === 'function') { try { record(removed); } catch { /* the removal stands without its event */ } }
|
|
58
|
-
return removed;
|
|
59
|
-
};
|
|
60
|
-
|
|
61
|
-
/**
|
|
62
|
-
* Recover `repo`'s stale index lock. `staleMs` is the age a lock must pass (allocation.housekeeping.
|
|
63
|
-
* gitIndexLockStaleMs); `apply` false reports `would-remove` and removes nothing; `list()` is the caller's git-process
|
|
64
|
-
* probe ([{pid, name, commandLine}] or null; none given: probe-failed, nothing removed); `record(result)` is called once
|
|
65
|
-
* for a removal.
|
|
66
|
-
* Returns {repo, lock, state, ageMs?, holders?, error?}; state is one of absent | fresh | not-a-file | probe-failed |
|
|
67
|
-
* held | changed | would-remove | removed | error.
|
|
68
|
-
*/
|
|
69
|
-
export function indexLock({ repo, staleMs, now = Date.now(), apply = true, list = null, record = null } = {}) {
|
|
70
|
-
const initial = initialLockState(repo, staleMs, now);
|
|
71
|
-
if (initial.result) return initial.result;
|
|
72
|
-
const { out, before } = initial;
|
|
73
|
-
const { lock } = out;
|
|
74
|
-
const holders = processHoldersOnRepo(list, out.repo);
|
|
75
|
-
if (holders === null) return { ...out, state: 'probe-failed' };
|
|
76
|
-
if (holders.length) return { ...out, state: 'held', holders: holders.map((p) => ({ pid: p.pid, name: p.name, commandLine: String(p.commandLine ?? '').slice(0, 200) })) };
|
|
77
|
-
return removeVerifiedLock({ lock, out, before, apply, record });
|
|
78
|
-
}
|
|
@@ -1,5 +0,0 @@
|
|
|
1
|
-
// log.mjs — `git log <args>`: the commit history in a caller-chosen format.
|
|
2
|
-
import { runGit } from './lib.mjs';
|
|
3
|
-
|
|
4
|
-
/** The spawn result {status, stdout, stderr, error} of `git log ...args`; options are runGit's (cwd or dir, config, timeout, maxBuffer, input, encoding, env, git). */
|
|
5
|
-
export const log = (args = [], options = {}) => runGit(['log', ...args], options);
|
|
@@ -1,5 +0,0 @@
|
|
|
1
|
-
// merge-tree.mjs — `git merge-tree <args>`: a merge computed without touching the index or the working tree.
|
|
2
|
-
import { runGit } from './lib.mjs';
|
|
3
|
-
|
|
4
|
-
/** The spawn result {status, stdout, stderr, error} of `git merge-tree ...args`; options are runGit's (cwd or dir, config, timeout, maxBuffer, input, encoding, env, git). */
|
|
5
|
-
export const mergeTree = (args = [], options = {}) => runGit(['merge-tree', ...args], options);
|