@b00dea/cockpit 0.1.0 → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +99 -0
- package/README.md +3 -0
- package/cockpit.schema.json +73 -3
- package/dist/autonomy.d.ts +3 -2
- package/dist/autonomy.js +30 -13
- package/dist/browser/commands.d.ts +57 -2
- package/dist/browser/commands.js +114 -10
- package/dist/browser/runtime.d.ts +1 -0
- package/dist/browser/runtime.js +4 -1
- package/dist/browser/surfaces.d.ts +7 -7
- package/dist/browser/surfaces.js +5 -0
- package/dist/canonical-json.d.ts +2 -0
- package/dist/canonical-json.js +5 -0
- package/dist/cli-main.d.ts +40 -3
- package/dist/cli-main.js +241 -62
- package/dist/command-table.d.ts +19 -11
- package/dist/command-table.js +190 -38
- package/dist/config/load.d.ts +16 -4
- package/dist/config/load.js +25 -3
- package/dist/config/unconfirmed-toml.d.ts +9 -0
- package/dist/config/unconfirmed-toml.js +29 -0
- package/dist/diagnostics.js +10 -1
- package/dist/docs/check.d.ts +16 -0
- package/dist/docs/check.js +197 -0
- package/dist/docs/decision-record.d.ts +34 -0
- package/dist/docs/decision-record.js +135 -0
- package/dist/docs/decisions.d.ts +16 -0
- package/dist/docs/decisions.js +103 -0
- package/dist/docs/markdown.d.ts +36 -0
- package/dist/docs/markdown.js +88 -0
- package/dist/docs/references.d.ts +32 -0
- package/dist/docs/references.js +148 -0
- package/dist/environment/application/confirm.d.ts +35 -0
- package/dist/environment/application/confirm.js +122 -0
- package/dist/environment/application/destructive.js +6 -2
- package/dist/environment/application/doctor.d.ts +12 -7
- package/dist/environment/application/doctor.js +20 -8
- package/dist/environment/application/guard.d.ts +12 -6
- package/dist/environment/application/guard.js +87 -32
- package/dist/environment/application/health.d.ts +14 -5
- package/dist/environment/application/health.js +8 -2
- package/dist/environment/application/lifecycle-context.d.ts +10 -3
- package/dist/environment/application/lifecycle-state.js +4 -3
- package/dist/environment/application/lifecycle.d.ts +2 -1
- package/dist/environment/application/lifecycle.js +11 -3
- package/dist/environment/application/services.d.ts +60 -0
- package/dist/environment/application/services.js +89 -0
- package/dist/environment/application/siblings.d.ts +43 -0
- package/dist/environment/application/siblings.js +269 -0
- package/dist/environment/claims.d.ts +10 -10
- package/dist/environment/claims.js +3 -11
- package/dist/environment/compose-topology.d.ts +15 -7
- package/dist/environment/compose-topology.js +100 -23
- package/dist/environment/contract.d.ts +42 -9
- package/dist/environment/contract.js +33 -0
- package/dist/environment/executable.d.ts +12 -0
- package/dist/environment/executable.js +46 -23
- package/dist/environment/generated-application/check.js +2 -0
- package/dist/environment/generated-application/prepare.js +2 -0
- package/dist/environment/generated-application/shared.d.ts +10 -2
- package/dist/environment/generated-application/shared.js +6 -1
- package/dist/environment/generated-application/surface-outputs.js +23 -12
- package/dist/environment/generated-application/surfaces.js +16 -11
- package/dist/environment/generated-application/sync.js +19 -4
- package/dist/environment/git-observation.d.ts +25 -6
- package/dist/environment/git-observation.js +37 -28
- package/dist/environment/health-composition.d.ts +3 -2
- package/dist/environment/health-composition.js +19 -2
- package/dist/environment/init-application.d.ts +39 -14
- package/dist/environment/init-application.js +39 -10
- package/dist/environment/lifecycle-composition.d.ts +4 -2
- package/dist/environment/lifecycle-composition.js +84 -17
- package/dist/environment/lifecycle-stage.d.ts +8 -3
- package/dist/environment/lifecycle-stage.js +11 -1
- package/dist/environment/logs.d.ts +1 -1
- package/dist/environment/map-limit.d.ts +2 -0
- package/dist/environment/map-limit.js +13 -0
- package/dist/environment/mcp-observation.d.ts +9 -0
- package/dist/environment/mcp-observation.js +46 -0
- package/dist/environment/observations.d.ts +1 -1
- package/dist/environment/observations.js +2 -0
- package/dist/environment/operator-application.d.ts +35 -6
- package/dist/environment/operator-application.js +42 -0
- package/dist/environment/presence.d.ts +4 -0
- package/dist/environment/presence.js +26 -0
- package/dist/environment/read-composition.d.ts +8 -3
- package/dist/environment/read-composition.js +53 -8
- package/dist/environment/readiness-composition.d.ts +20 -1
- package/dist/environment/readiness-composition.js +23 -2
- package/dist/environment/reclaim.d.ts +56 -19
- package/dist/environment/reclaim.js +147 -42
- package/dist/environment/runtime.d.ts +64 -18
- package/dist/environment/runtime.js +73 -3
- package/dist/environment/served-checkout-grants.d.ts +53 -0
- package/dist/environment/served-checkout-grants.js +182 -0
- package/dist/environment/sibling-checkouts.d.ts +119 -0
- package/dist/environment/sibling-checkouts.js +254 -0
- package/dist/environment/sibling-observation.d.ts +27 -0
- package/dist/environment/sibling-observation.js +45 -0
- package/dist/environment/sibling-pull.d.ts +23 -0
- package/dist/environment/sibling-pull.js +95 -0
- package/dist/environment/stack-inputs.d.ts +18 -0
- package/dist/environment/stack-inputs.js +210 -0
- package/dist/environment/stack-inventory-prune.d.ts +31 -0
- package/dist/environment/stack-inventory-prune.js +129 -0
- package/dist/environment/stack-inventory-report.d.ts +44 -0
- package/dist/environment/stack-inventory-report.js +78 -0
- package/dist/environment/stack-inventory.d.ts +94 -0
- package/dist/environment/stack-inventory.js +418 -0
- package/dist/environment/state.d.ts +48 -9
- package/dist/environment/state.js +142 -2
- package/dist/environment/tool-registration.js +3 -7
- package/dist/environment-exec/execute.d.ts +6 -3
- package/dist/environment-exec/execute.js +4 -0
- package/dist/fix-catalogue.d.ts +1 -1
- package/dist/generated-environment/adopt.js +4 -8
- package/dist/guards/answer-confirmation.d.ts +41 -0
- package/dist/guards/answer-confirmation.js +109 -0
- package/dist/guards/evaluator.d.ts +20 -3
- package/dist/guards/evaluator.js +39 -7
- package/dist/guards/local-identity.d.ts +6 -2
- package/dist/guards/local-identity.js +10 -4
- package/dist/guards/policy-change.d.ts +43 -0
- package/dist/guards/policy-change.js +159 -0
- package/dist/guards/policy-pin.d.ts +1 -1
- package/dist/guards/policy-pin.js +14 -12
- package/dist/init/command.d.ts +4 -3
- package/dist/init/command.js +36 -16
- package/dist/init/derive.d.ts +8 -6
- package/dist/init/derive.js +140 -36
- package/dist/init/draft.d.ts +13 -11
- package/dist/init/draft.js +54 -31
- package/dist/init/render.d.ts +18 -6
- package/dist/init/render.js +31 -9
- package/dist/repository-path.d.ts +9 -0
- package/dist/repository-path.js +16 -0
- package/dist/schema.d.ts +42 -75
- package/dist/schema.js +62 -11
- package/dist/surfaces/harness-registry.d.ts +80 -0
- package/dist/surfaces/harness-registry.js +29 -0
- package/dist/surfaces/mcp.d.ts +67 -17
- package/dist/surfaces/mcp.js +194 -31
- package/dist/surfaces/projection.d.ts +7 -7
- package/dist/surfaces/projection.js +41 -6
- package/dist/surfaces/render.d.ts +5 -44
- package/dist/surfaces/render.js +5 -2
- package/dist/team-metadata.js +2 -1
- package/dist/testing/test-protection.d.ts +34 -0
- package/dist/testing/test-protection.js +137 -0
- package/dist/testing/weakening.d.ts +15 -0
- package/dist/testing/weakening.js +30 -0
- package/dist/work-orders/application-closure.d.ts +7 -0
- package/dist/work-orders/application-closure.js +76 -0
- package/dist/work-orders/application-context.d.ts +162 -0
- package/dist/work-orders/application-context.js +137 -0
- package/dist/work-orders/application-decisions.d.ts +27 -0
- package/dist/work-orders/application-decisions.js +121 -0
- package/dist/work-orders/application-grading.d.ts +11 -0
- package/dist/work-orders/application-grading.js +212 -0
- package/dist/work-orders/application.d.ts +4 -97
- package/dist/work-orders/application.js +94 -254
- package/dist/work-orders/composition.d.ts +35 -58
- package/dist/work-orders/composition.js +216 -24
- package/dist/work-orders/contract.d.ts +22 -6
- package/dist/work-orders/contract.js +34 -16
- package/dist/work-orders/decisions.d.ts +23 -0
- package/dist/work-orders/decisions.js +55 -0
- package/dist/work-orders/store.d.ts +54 -5
- package/dist/work-orders/store.js +118 -24
- package/docs/README.md +6 -3
- package/docs/capability-matrix.md +17 -9
- package/docs/cli-reference.md +209 -1
- package/docs/command-list.md +12 -1
- package/docs/concepts.md +6 -4
- package/docs/configuration-essentials.md +7 -2
- package/docs/configuration-reference.md +18 -3
- package/docs/docs-check.md +76 -0
- package/docs/environment.md +194 -6
- package/docs/faq.md +5 -3
- package/docs/getting-started.md +109 -25
- package/docs/harness-surfaces.md +69 -2
- package/docs/safety-and-autonomy.md +56 -12
- package/docs/troubleshooting.md +29 -8
- package/docs/tutorial.md +3 -2
- package/docs/work-and-browser.md +180 -14
- package/libexec/browser-operation.cjs +86 -2
- package/package.json +2 -2
- package/surfaces/skills/cockpit-browser.skill.md +7 -2
- package/surfaces/skills/cockpit-explain.skill.md +1 -1
- package/surfaces/skills/cockpit-fix.skill.md +1 -1
- package/surfaces/skills/cockpit-setup.skill.md +3 -3
- package/surfaces/skills/cockpit-work.skill.md +1 -1
- package/surfaces/skills/procedures/explain.md +9 -4
- package/surfaces/skills/procedures/fix.md +25 -11
- package/surfaces/skills/procedures/setup.md +113 -40
- package/surfaces/skills/procedures/work.md +45 -16
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Grants the agent tools a sibling checkout `use` serves from outside the declared one. The grants
|
|
3
|
+
* `sync` writes are committed and name the declared checkout, the same in every clone; a worktree
|
|
4
|
+
* path is one person's disk, so it goes only where a tool reads a machine-local setting.
|
|
5
|
+
*
|
|
6
|
+
* Measured on 2026-11-04 beside an ungranted control directory, each run writing into both:
|
|
7
|
+
* - Claude Code 2.1.318 (`claude -p --permission-mode acceptEdits`): a directory listed in
|
|
8
|
+
* `.claude/settings.local.json` `permissions.additionalDirectories` was written and the control
|
|
9
|
+
* refused; a twin whose list was a string was named as not loaded and granted nothing. The run
|
|
10
|
+
* was in an untrusted workspace, where Claude ignored the committed `.claude/settings.json` list,
|
|
11
|
+
* so whether the two lists combine once a person trusts the workspace was not measured.
|
|
12
|
+
* - OpenCode 1.19.5 (`opencode run`): the `{file:...}` grant file `sync` writes, holding the
|
|
13
|
+
* worktree path, allowed the write and the control was denied; a pattern naming a missing grant
|
|
14
|
+
* file stopped OpenCode at start, naming the file.
|
|
15
|
+
* - Codex 0.166.0 (`codex exec`): no project file Codex reads is machine-local. Its project
|
|
16
|
+
* `.codex/config.toml` is committed and its only per-project user setting is the trust level;
|
|
17
|
+
* `--add-dir <dir>` at start made the worktree writable beside the committed roots, and without
|
|
18
|
+
* it the same write was refused.
|
|
19
|
+
* Cursor and Grok Build were not installed, so nothing is written for them.
|
|
20
|
+
*/
|
|
21
|
+
import { lstat, mkdir, rename, rm, writeFile } from 'node:fs/promises';
|
|
22
|
+
import { basename, join, sep } from 'node:path';
|
|
23
|
+
import { readBoundedFile } from '../bounded-read.js';
|
|
24
|
+
import { GUARD_LOCAL_PATHS, writeCockpitLocalFiles } from '../guards/local-identity.js';
|
|
25
|
+
import { ENVIRONMENT_CODES, errorCause, isMissing, tryParseJson } from './contract.js';
|
|
26
|
+
import { openCodeGrantFile } from './generated-application/surface-outputs.js';
|
|
27
|
+
const CLAUDE_LOCAL_SETTINGS = '.claude/settings.local.json';
|
|
28
|
+
// What cockpit put in Claude's local settings, so a person's own entries are never taken back.
|
|
29
|
+
const ADDED_NAME = 'claude-local-grants';
|
|
30
|
+
const ADDED_PATH = `${GUARD_LOCAL_PATHS.directory}/${ADDED_NAME}`;
|
|
31
|
+
const ADDED_FORMAT = 'cockpit-claude-local-grants/v1';
|
|
32
|
+
const SETTINGS_MAX_BYTES = 1_048_576;
|
|
33
|
+
export function inside(path, root) {
|
|
34
|
+
return path === root || path.startsWith(`${root}${sep}`);
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* The directory the OpenCode grant file names: the served checkout when it lies outside the
|
|
38
|
+
* declared one, which the grant would not otherwise reach.
|
|
39
|
+
*/
|
|
40
|
+
export function openCodeGrantDirectory(declared, served) {
|
|
41
|
+
return served !== undefined && !inside(served, declared) ? served : declared;
|
|
42
|
+
}
|
|
43
|
+
/** Why a directory cannot go into an OpenCode access pattern, or undefined when it can. */
|
|
44
|
+
export function openCodePatternUnsafe(directory) {
|
|
45
|
+
// The file's bytes become part of an OpenCode pattern: a wildcard would widen the grant and a
|
|
46
|
+
// quote or backslash would change the JSON around it.
|
|
47
|
+
return /[*?"\\\x00-\x1f\x7f]/.test(directory)
|
|
48
|
+
? `${directory} contains *, ?, a quote, a backslash or a control character, which OpenCode would read as part of its access pattern`
|
|
49
|
+
: undefined;
|
|
50
|
+
}
|
|
51
|
+
const HARNESS_NAMES = { codexcli: 'Codex', cursor: 'Cursor', grokcli: 'Grok Build' };
|
|
52
|
+
function grantRefusal(message, target, fix) {
|
|
53
|
+
return { kind: 'refusal', diagnostic: { severity: 'error', code: ENVIRONMENT_CODES.SIBLING_CHECKOUT_RECORD_INVALID, message, target, fix } };
|
|
54
|
+
}
|
|
55
|
+
function plainObject(value) {
|
|
56
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
57
|
+
}
|
|
58
|
+
/** The JSON text of a local file, or undefined when there is none; a link or unreadable file is a reason. */
|
|
59
|
+
function readLocalJson(path) {
|
|
60
|
+
try {
|
|
61
|
+
const read = readBoundedFile(path, SETTINGS_MAX_BYTES);
|
|
62
|
+
if (read.kind === 'too-large')
|
|
63
|
+
return { reason: `it is larger than ${SETTINGS_MAX_BYTES} bytes` };
|
|
64
|
+
const bytes = read.bytes.toString('utf8');
|
|
65
|
+
return { value: tryParseJson(bytes), bytes };
|
|
66
|
+
}
|
|
67
|
+
catch (error) {
|
|
68
|
+
if (isMissing(error))
|
|
69
|
+
return undefined;
|
|
70
|
+
return { reason: `it could not be read as a plain file: ${errorCause(error)}` };
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Plans the machine-local grants for one sibling. `served` is the checkout that serves it, or
|
|
75
|
+
* undefined when the sibling is no longer declared; `targets` are the listed tools that hold a
|
|
76
|
+
* committed grant for it. A grant cockpit added earlier for the sibling is taken back when the
|
|
77
|
+
* served checkout no longer needs it.
|
|
78
|
+
*/
|
|
79
|
+
export async function planAgentGrants(input) {
|
|
80
|
+
const outside = input.declared !== undefined && input.served !== undefined && !inside(input.served, input.declared) ? input.served : undefined;
|
|
81
|
+
const addedPath = join(input.root, ADDED_PATH);
|
|
82
|
+
const recorded = readLocalJson(addedPath);
|
|
83
|
+
const addedFix = `delete ${addedPath}, remove the additionalDirectories entries cockpit added from ${CLAUDE_LOCAL_SETTINGS}, then run this command again`;
|
|
84
|
+
if (recorded && 'reason' in recorded)
|
|
85
|
+
return grantRefusal(`${addedPath} could not be read: ${recorded.reason}`, ADDED_PATH, addedFix);
|
|
86
|
+
const record = recorded?.value;
|
|
87
|
+
if (recorded && !(plainObject(record) && record.format === ADDED_FORMAT && plainObject(record.directories)
|
|
88
|
+
&& Object.values(record.directories).every(directory => typeof directory === 'string'))) {
|
|
89
|
+
return grantRefusal(`${addedPath} is not a record cockpit wrote (it does not carry the ${ADDED_FORMAT} format)`, ADDED_PATH, addedFix);
|
|
90
|
+
}
|
|
91
|
+
const added = { ...(plainObject(record) ? record.directories : {}) };
|
|
92
|
+
const previous = Object.hasOwn(added, input.slug) ? added[input.slug] : undefined;
|
|
93
|
+
delete added[input.slug];
|
|
94
|
+
const wantClaude = outside !== undefined && input.targets.includes('claudecode') ? outside : undefined;
|
|
95
|
+
let settings;
|
|
96
|
+
if (previous !== undefined || wantClaude !== undefined) {
|
|
97
|
+
const settingsPath = join(input.root, CLAUDE_LOCAL_SETTINGS);
|
|
98
|
+
const settingsFix = `make ${settingsPath} a JSON object whose permissions.additionalDirectories is a list of paths, or remove it, then run this command again`;
|
|
99
|
+
const current = readLocalJson(settingsPath);
|
|
100
|
+
if (current && 'reason' in current)
|
|
101
|
+
return grantRefusal(`${settingsPath} could not be read: ${current.reason}`, CLAUDE_LOCAL_SETTINGS, settingsFix);
|
|
102
|
+
const object = current === undefined ? {} : current.value;
|
|
103
|
+
const permissions = plainObject(object) ? object.permissions ?? {} : undefined;
|
|
104
|
+
const listed = plainObject(permissions) ? permissions.additionalDirectories ?? [] : undefined;
|
|
105
|
+
// Claude Code drops a whole settings file it cannot load, so cockpit never rewrites one it cannot read either.
|
|
106
|
+
if (!plainObject(object) || !plainObject(permissions) || !Array.isArray(listed) || !listed.every(entry => typeof entry === 'string')) {
|
|
107
|
+
return grantRefusal(`${settingsPath} is not a Claude Code settings object cockpit can add a directory to`, CLAUDE_LOCAL_SETTINGS, settingsFix);
|
|
108
|
+
}
|
|
109
|
+
let directories = listed;
|
|
110
|
+
if (previous !== undefined && previous !== wantClaude && !Object.values(added).includes(previous)) {
|
|
111
|
+
directories = directories.filter(directory => directory !== previous);
|
|
112
|
+
}
|
|
113
|
+
if (wantClaude !== undefined) {
|
|
114
|
+
// A directory the person listed themselves stays theirs: cockpit neither records nor removes it.
|
|
115
|
+
if (previous === wantClaude || !directories.includes(wantClaude))
|
|
116
|
+
added[input.slug] = wantClaude;
|
|
117
|
+
if (!directories.includes(wantClaude))
|
|
118
|
+
directories = [...directories, wantClaude];
|
|
119
|
+
}
|
|
120
|
+
if (directories.length !== listed.length || directories.some((directory, index) => directory !== listed[index])) {
|
|
121
|
+
const { additionalDirectories: _replaced, ...otherPermissions } = permissions;
|
|
122
|
+
const nextPermissions = directories.length > 0 ? { ...otherPermissions, additionalDirectories: directories } : otherPermissions;
|
|
123
|
+
const { permissions: _permissions, ...otherSettings } = object;
|
|
124
|
+
const next = Object.keys(nextPermissions).length > 0 ? { ...otherSettings, permissions: nextPermissions } : otherSettings;
|
|
125
|
+
settings = { bytes: Object.keys(next).length > 0 ? `${JSON.stringify(next, null, 2)}\n` : null };
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
const local = [];
|
|
129
|
+
if (previous !== added[input.slug])
|
|
130
|
+
local.push({ name: ADDED_NAME, bytes: `${JSON.stringify({ format: ADDED_FORMAT, directories: added })}\n` });
|
|
131
|
+
if (input.declared !== undefined && input.targets.includes('opencode')) {
|
|
132
|
+
const directory = openCodeGrantDirectory(input.declared, input.served);
|
|
133
|
+
const unsafe = openCodePatternUnsafe(directory);
|
|
134
|
+
if (unsafe)
|
|
135
|
+
return grantRefusal(`OpenCode cannot be granted the checkout: ${unsafe}`, directory, 'move the checkout to a plain path, then run this command again');
|
|
136
|
+
local.push({ name: basename(openCodeGrantFile(input.slug)), bytes: directory });
|
|
137
|
+
}
|
|
138
|
+
if (outside === undefined)
|
|
139
|
+
return { kind: 'complete', plan: { settings, local, lines: [], report: undefined } };
|
|
140
|
+
const granted = input.targets.filter(target => target === 'claudecode' || target === 'opencode');
|
|
141
|
+
const ungranted = input.targets.filter(target => target !== 'claudecode' && target !== 'opencode');
|
|
142
|
+
const lines = [
|
|
143
|
+
...granted.map(target => `${target === 'claudecode' ? 'Claude Code' : 'OpenCode'} may edit ${outside} through ${target === 'claudecode' ? CLAUDE_LOCAL_SETTINGS : openCodeGrantFile(input.slug)}, which only this machine reads; restart a session that is already running.`),
|
|
144
|
+
...ungranted.map(target => target === 'codexcli'
|
|
145
|
+
? `Codex has no machine-local setting for extra writable roots, so its sandbox still reaches only ${input.declared}; start Codex with --add-dir ${outside}.`
|
|
146
|
+
: `${HARNESS_NAMES[target] ?? target} was not measured for a machine-local grant, so its grant still names only ${input.declared} and it may ask before editing ${outside}.`),
|
|
147
|
+
];
|
|
148
|
+
return { kind: 'complete', plan: { settings, local, lines, report: { granted, ungranted } } };
|
|
149
|
+
}
|
|
150
|
+
/** Writes a plan: cockpit's own record first, so a grant it added is never one it forgets. */
|
|
151
|
+
export async function applyAgentGrants(root, plan) {
|
|
152
|
+
if (plan.local.length > 0) {
|
|
153
|
+
const written = await writeCockpitLocalFiles(root, plan.local);
|
|
154
|
+
if (written.kind !== 'complete')
|
|
155
|
+
return grantRefusal(`the agent tools' machine-local grants could not be written: ${written.message}`, GUARD_LOCAL_PATHS.directory, 'make .cockpit/local a plain directory you can write, then run this command again');
|
|
156
|
+
}
|
|
157
|
+
if (plan.settings === undefined)
|
|
158
|
+
return { kind: 'complete' };
|
|
159
|
+
const path = join(root, CLAUDE_LOCAL_SETTINGS);
|
|
160
|
+
try {
|
|
161
|
+
if (plan.settings.bytes === null) {
|
|
162
|
+
await rm(path);
|
|
163
|
+
return { kind: 'complete' };
|
|
164
|
+
}
|
|
165
|
+
const directory = join(root, '.claude');
|
|
166
|
+
await mkdir(directory, { recursive: true });
|
|
167
|
+
if ((await lstat(directory)).isSymbolicLink())
|
|
168
|
+
throw new Error(`${directory} is a symbolic link`);
|
|
169
|
+
const temporary = join(directory, `.settings.local.json.${process.pid}.tmp`);
|
|
170
|
+
try {
|
|
171
|
+
await writeFile(temporary, plan.settings.bytes, { flag: 'wx' });
|
|
172
|
+
await rename(temporary, path);
|
|
173
|
+
}
|
|
174
|
+
finally {
|
|
175
|
+
await rm(temporary, { force: true });
|
|
176
|
+
}
|
|
177
|
+
return { kind: 'complete' };
|
|
178
|
+
}
|
|
179
|
+
catch (error) {
|
|
180
|
+
return grantRefusal(`${path} could not be written: ${errorCause(error)}`, CLAUDE_LOCAL_SETTINGS, 'make .claude a plain directory you can write, then run this command again');
|
|
181
|
+
}
|
|
182
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { type ExecuteProbeOutcome } from '../environment-exec/execute.js';
|
|
2
|
+
import type { CockpitConfig, Sibling } from '../schema.js';
|
|
3
|
+
import { type EnvironmentDiagnostic, type ExecutionStep } from './contract.js';
|
|
4
|
+
import { type ResolvedExecutable } from './executable.js';
|
|
5
|
+
export declare const SIBLING_CHECKOUTS_PATH: string;
|
|
6
|
+
/** Each overridden sibling's slug and the canonical absolute directory that serves it. */
|
|
7
|
+
type SiblingCheckoutRecord = Readonly<Record<string, string>>;
|
|
8
|
+
export type SiblingCheckoutRecordRead = Readonly<{
|
|
9
|
+
kind: 'complete';
|
|
10
|
+
checkouts: SiblingCheckoutRecord;
|
|
11
|
+
}> | Readonly<{
|
|
12
|
+
kind: 'refusal';
|
|
13
|
+
diagnostic: EnvironmentDiagnostic;
|
|
14
|
+
}>;
|
|
15
|
+
/** The recorded overrides; none when nothing was recorded. A record cockpit did not write is refused, never ignored. */
|
|
16
|
+
export declare function readSiblingCheckouts(cockpitRoot: string): Promise<SiblingCheckoutRecordRead>;
|
|
17
|
+
export declare function writeSiblingCheckouts(cockpitRoot: string, checkouts: SiblingCheckoutRecord): Promise<{
|
|
18
|
+
readonly kind: 'complete';
|
|
19
|
+
} | {
|
|
20
|
+
readonly kind: 'refusal';
|
|
21
|
+
readonly message: string;
|
|
22
|
+
}>;
|
|
23
|
+
export interface ServedSibling {
|
|
24
|
+
readonly slug: string;
|
|
25
|
+
/** Where cockpit.toml puts the checkout. */
|
|
26
|
+
readonly declared: string;
|
|
27
|
+
/** The checkout the stack, the guard and the health commands use. */
|
|
28
|
+
readonly served: string;
|
|
29
|
+
readonly overridden: boolean;
|
|
30
|
+
}
|
|
31
|
+
/** Every declared sibling with the directory that serves it; an override for an undeclared slug serves nothing. */
|
|
32
|
+
export declare function servedSiblings(cockpitRoot: string, config: Readonly<{
|
|
33
|
+
cockpit: Readonly<{
|
|
34
|
+
siblings_root: string;
|
|
35
|
+
}>;
|
|
36
|
+
sibling: readonly Readonly<{
|
|
37
|
+
slug: string;
|
|
38
|
+
dir: string;
|
|
39
|
+
}>[];
|
|
40
|
+
}>, checkouts: SiblingCheckoutRecord): ReadonlyMap<string, ServedSibling>;
|
|
41
|
+
/** The directory serving a declared sibling; a slug the map lacks is a caller's error, named as one. */
|
|
42
|
+
export declare function servedPath(served: ReadonlyMap<string, ServedSibling>, slug: string): string;
|
|
43
|
+
type ServedSiblingsRead = Readonly<{
|
|
44
|
+
kind: 'complete';
|
|
45
|
+
siblings: ReadonlyMap<string, ServedSibling>;
|
|
46
|
+
}> | Readonly<{
|
|
47
|
+
kind: 'refusal';
|
|
48
|
+
diagnostic: EnvironmentDiagnostic;
|
|
49
|
+
}>;
|
|
50
|
+
export declare function loadServedSiblings(cockpitRoot: string, config: Pick<CockpitConfig, 'cockpit' | 'sibling'>): Promise<ServedSiblingsRead>;
|
|
51
|
+
/** The overridden siblings whose recorded directory is gone, such as a removed worktree. */
|
|
52
|
+
export declare function vanishedServedCheckouts(served: ReadonlyMap<string, ServedSibling>): Promise<readonly ServedSibling[]>;
|
|
53
|
+
export declare function vanishedCheckoutFix(slug: string): string;
|
|
54
|
+
/**
|
|
55
|
+
* A refusal when an override names a directory that is gone. Docker creates a missing bind-mount
|
|
56
|
+
* source as an empty directory, so the stack would start on nothing. Preflight and doctor report
|
|
57
|
+
* the same fact as the served-checkouts observation row, so all three agree.
|
|
58
|
+
*/
|
|
59
|
+
export declare function missingServedCheckout(served: ReadonlyMap<string, ServedSibling>): Promise<EnvironmentDiagnostic | undefined>;
|
|
60
|
+
/** The directory variables Compose interpolates: each sibling's `var` and the directory that serves it. */
|
|
61
|
+
export declare function servedSiblingVariables(config: Pick<CockpitConfig, 'sibling'>, served: ReadonlyMap<string, ServedSibling>): Readonly<Record<string, string>>;
|
|
62
|
+
type GitRun = Readonly<{
|
|
63
|
+
kind: 'complete';
|
|
64
|
+
stdout: string;
|
|
65
|
+
}> | Readonly<{
|
|
66
|
+
kind: 'failed';
|
|
67
|
+
exitCode: number;
|
|
68
|
+
code: string;
|
|
69
|
+
detail: string;
|
|
70
|
+
stderr: string;
|
|
71
|
+
}>;
|
|
72
|
+
/**
|
|
73
|
+
* `stdoutLimitBytes` replaces the default bound, which keeps only the tail of longer output, with a
|
|
74
|
+
* hard one: output past it fails with EXECUTION_STDOUT_LIMIT instead of arriving in part.
|
|
75
|
+
*/
|
|
76
|
+
type GitRunOptions = Readonly<{
|
|
77
|
+
timeoutMs?: number;
|
|
78
|
+
stdoutLimitBytes?: number;
|
|
79
|
+
}>;
|
|
80
|
+
/** Runs one Git command in a checkout through the authenticated executor and returns its text. */
|
|
81
|
+
export interface GitRunner {
|
|
82
|
+
readonly executable: ResolvedExecutable;
|
|
83
|
+
run(cwd: string, id: string, args: readonly string[], options?: GitRunOptions): Promise<GitRun>;
|
|
84
|
+
}
|
|
85
|
+
export declare const GIT_CHILD_CONFIG: readonly ['-c', 'core.fsmonitor=false', '-c', 'core.hooksPath=/dev/null'];
|
|
86
|
+
export declare function createGitRunner(env: Readonly<Record<string, string | undefined>>, execute: (step: ExecutionStep) => Promise<ExecuteProbeOutcome>): Promise<GitRunner | Readonly<{
|
|
87
|
+
unavailable: string;
|
|
88
|
+
}>>;
|
|
89
|
+
type SiblingCheckoutInspection = Readonly<{
|
|
90
|
+
kind: 'same-repository';
|
|
91
|
+
directory: string;
|
|
92
|
+
evidence: 'object-store' | 'remote';
|
|
93
|
+
}> | Readonly<{
|
|
94
|
+
kind: 'refusal';
|
|
95
|
+
diagnostic: EnvironmentDiagnostic;
|
|
96
|
+
}>;
|
|
97
|
+
/**
|
|
98
|
+
* Whether `candidate` is the root of a checkout of the sibling's repository: it shares the declared
|
|
99
|
+
* checkout's object store (a linked worktree), or its origin names the same repository as the
|
|
100
|
+
* declared checkout's origin or the declared remote. Anything else is refused: serving an unrelated
|
|
101
|
+
* repository under a sibling's name would hand its code the sibling's mounts and grants.
|
|
102
|
+
*/
|
|
103
|
+
export declare function inspectSiblingCheckout(input: Readonly<{
|
|
104
|
+
sibling: Pick<Sibling, 'slug' | 'remote'>;
|
|
105
|
+
declared: string;
|
|
106
|
+
candidate: string;
|
|
107
|
+
git: GitRunner;
|
|
108
|
+
}>): Promise<SiblingCheckoutInspection>;
|
|
109
|
+
/** The declared environment files the served checkout lacks; a fresh worktree has none of the ignored ones. */
|
|
110
|
+
export declare function missingEnvironmentFiles(served: ServedSibling, sibling: Pick<Sibling, 'environment_files'>): Promise<readonly Readonly<{
|
|
111
|
+
template: string;
|
|
112
|
+
instance: string;
|
|
113
|
+
}>[]>;
|
|
114
|
+
/** One warning per environment file an overriding checkout lacks, naming who must create it and from what. */
|
|
115
|
+
export declare function missingEnvironmentFileDiagnostics(served: ServedSibling, missing: readonly Readonly<{
|
|
116
|
+
template: string;
|
|
117
|
+
instance: string;
|
|
118
|
+
}>[]): readonly EnvironmentDiagnostic[];
|
|
119
|
+
export {};
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Owns which checkout serves each sibling: the directory cockpit.toml declares under siblings_root,
|
|
3
|
+
* or another checkout of the same repository (a linked worktree, usually) that `cockpit use`
|
|
4
|
+
* recorded for this cockpit checkout alone. Mounts, the guard's allowed roots and the health
|
|
5
|
+
* commands all read the served directory from here, so they describe the same code.
|
|
6
|
+
*
|
|
7
|
+
* The record is machine-local, under `.cockpit/local`, never in cockpit.toml: a worktree path is
|
|
8
|
+
* one person's disk, and a committed one would point every other clone at a directory it lacks.
|
|
9
|
+
* Without it, an agent working on a branch in a sibling's worktree had the stack, the checks and
|
|
10
|
+
* every grade run against the main checkout's branch while reporting green.
|
|
11
|
+
*/
|
|
12
|
+
import { lstatIfPresent } from './presence.js';
|
|
13
|
+
import { lstat, realpath } from 'node:fs/promises';
|
|
14
|
+
import { isAbsolute, join, normalize, resolve } from 'node:path';
|
|
15
|
+
import { authenticatedExecutionOutcome } from '../environment-exec/execute.js';
|
|
16
|
+
import { readBoundedFile } from '../bounded-read.js';
|
|
17
|
+
import { cockpitCommand, formatShellCommand } from '../fix-catalogue.js';
|
|
18
|
+
import { GUARD_LOCAL_PATHS, writeCockpitLocalFiles } from '../guards/local-identity.js';
|
|
19
|
+
import { ENVIRONMENT_CODES, errorCause, hasErrorCode, RefusalError, tryParseJson } from './contract.js';
|
|
20
|
+
import { resolveGitExecutable } from './executable.js';
|
|
21
|
+
import { repositoryIdentity } from './repository-identity.js';
|
|
22
|
+
import { redactSensitiveMessage } from './sensitive.js';
|
|
23
|
+
import { gitEnvironment } from './setup.js';
|
|
24
|
+
const FORMAT = 'cockpit-sibling-checkouts/v1';
|
|
25
|
+
const RECORD_NAME = 'sibling-checkouts';
|
|
26
|
+
export const SIBLING_CHECKOUTS_PATH = `${GUARD_LOCAL_PATHS.directory}/${RECORD_NAME}`;
|
|
27
|
+
const RECORD_MAX_BYTES = 65_536;
|
|
28
|
+
const SLUG = /^[a-z0-9][a-z0-9-]*$/;
|
|
29
|
+
function canonicalDirectory(value) {
|
|
30
|
+
return typeof value === 'string' && isAbsolute(value) && normalize(value) === value && value !== '/'
|
|
31
|
+
&& !value.endsWith('/') && !/[\x00-\x1f\x7f]/.test(value);
|
|
32
|
+
}
|
|
33
|
+
function recordRefusal(path, reason) {
|
|
34
|
+
return { kind: 'refusal', diagnostic: {
|
|
35
|
+
severity: 'error',
|
|
36
|
+
code: 'SIBLING_CHECKOUT_RECORD_INVALID',
|
|
37
|
+
message: `${path} is not a sibling checkout record cockpit wrote (${reason}), so cockpit cannot tell which checkout serves each sibling`,
|
|
38
|
+
target: SIBLING_CHECKOUTS_PATH,
|
|
39
|
+
fix: `delete ${path}, then run npx --no cockpit use <slug> <checkout> --config <your cockpit.toml> again for each sibling another checkout should serve`,
|
|
40
|
+
} };
|
|
41
|
+
}
|
|
42
|
+
/** The recorded overrides; none when nothing was recorded. A record cockpit did not write is refused, never ignored. */
|
|
43
|
+
export async function readSiblingCheckouts(cockpitRoot) {
|
|
44
|
+
const path = join(cockpitRoot, SIBLING_CHECKOUTS_PATH);
|
|
45
|
+
let bytes;
|
|
46
|
+
try {
|
|
47
|
+
const read = readBoundedFile(path, RECORD_MAX_BYTES);
|
|
48
|
+
if (read.kind === 'too-large')
|
|
49
|
+
return recordRefusal(path, `it is larger than ${RECORD_MAX_BYTES} bytes`);
|
|
50
|
+
bytes = read.bytes;
|
|
51
|
+
}
|
|
52
|
+
catch (error) {
|
|
53
|
+
if (hasErrorCode(error, 'ENOENT'))
|
|
54
|
+
return { kind: 'complete', checkouts: {} };
|
|
55
|
+
// ELOOP: a link at the last component; EISDIR: the reader's code for a file that is not regular.
|
|
56
|
+
if (hasErrorCode(error, 'ELOOP', 'EISDIR'))
|
|
57
|
+
return recordRefusal(path, 'it is not a plain file');
|
|
58
|
+
return recordRefusal(path, `it could not be read: ${errorCause(error)}`);
|
|
59
|
+
}
|
|
60
|
+
const value = tryParseJson(bytes.toString('utf8'));
|
|
61
|
+
if (value === null || typeof value !== 'object' || Array.isArray(value))
|
|
62
|
+
return recordRefusal(path, 'it is not a JSON object');
|
|
63
|
+
const record = value;
|
|
64
|
+
if (Object.keys(record).sort().join(',') !== 'checkouts,format' || record.format !== FORMAT) {
|
|
65
|
+
return recordRefusal(path, `it does not carry the ${FORMAT} format`);
|
|
66
|
+
}
|
|
67
|
+
const checkouts = record.checkouts;
|
|
68
|
+
if (checkouts === null || typeof checkouts !== 'object' || Array.isArray(checkouts)
|
|
69
|
+
|| Object.entries(checkouts).some(([slug, directory]) => !SLUG.test(slug) || !canonicalDirectory(directory))) {
|
|
70
|
+
return recordRefusal(path, 'a slug or directory in it is malformed');
|
|
71
|
+
}
|
|
72
|
+
return { kind: 'complete', checkouts: Object.freeze({ ...checkouts }) };
|
|
73
|
+
}
|
|
74
|
+
export async function writeSiblingCheckouts(cockpitRoot, checkouts) {
|
|
75
|
+
const ordered = Object.fromEntries(Object.entries(checkouts).sort(([left], [right]) => left < right ? -1 : 1));
|
|
76
|
+
return writeCockpitLocalFiles(cockpitRoot, [{ name: RECORD_NAME, bytes: `${JSON.stringify({ format: FORMAT, checkouts: ordered })}\n` }]);
|
|
77
|
+
}
|
|
78
|
+
/** Every declared sibling with the directory that serves it; an override for an undeclared slug serves nothing. */
|
|
79
|
+
export function servedSiblings(cockpitRoot, config, checkouts) {
|
|
80
|
+
const siblingsRoot = resolve(cockpitRoot, config.cockpit.siblings_root);
|
|
81
|
+
return new Map(config.sibling.map(sibling => {
|
|
82
|
+
const declared = resolve(siblingsRoot, sibling.dir);
|
|
83
|
+
const override = Object.hasOwn(checkouts, sibling.slug) ? checkouts[sibling.slug] : undefined;
|
|
84
|
+
return [sibling.slug, Object.freeze({ slug: sibling.slug, declared, served: override ?? declared, overridden: override !== undefined })];
|
|
85
|
+
}));
|
|
86
|
+
}
|
|
87
|
+
/** The directory serving a declared sibling; a slug the map lacks is a caller's error, named as one. */
|
|
88
|
+
export function servedPath(served, slug) {
|
|
89
|
+
const entry = served.get(slug);
|
|
90
|
+
if (!entry)
|
|
91
|
+
throw new RefusalError(`sibling ${slug} has no served checkout; it is not declared in cockpit.toml`);
|
|
92
|
+
return entry.served;
|
|
93
|
+
}
|
|
94
|
+
export async function loadServedSiblings(cockpitRoot, config) {
|
|
95
|
+
const read = await readSiblingCheckouts(cockpitRoot);
|
|
96
|
+
return read.kind === 'complete' ? { kind: 'complete', siblings: servedSiblings(cockpitRoot, config, read.checkouts) } : read;
|
|
97
|
+
}
|
|
98
|
+
/** The overridden siblings whose recorded directory is gone, such as a removed worktree. */
|
|
99
|
+
export async function vanishedServedCheckouts(served) {
|
|
100
|
+
const overridden = [...served.values()].filter(sibling => sibling.overridden);
|
|
101
|
+
const present = await Promise.all(overridden.map(async (sibling) => (await lstatIfPresent(sibling.served))?.isDirectory() === true));
|
|
102
|
+
return overridden.filter((_, index) => !present[index]);
|
|
103
|
+
}
|
|
104
|
+
export function vanishedCheckoutFix(slug) {
|
|
105
|
+
return `serve the declared checkout again with ${cockpitCommand('use', { operand: slug, args: '--clear' })}, or name another checkout of it`;
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* A refusal when an override names a directory that is gone. Docker creates a missing bind-mount
|
|
109
|
+
* source as an empty directory, so the stack would start on nothing. Preflight and doctor report
|
|
110
|
+
* the same fact as the served-checkouts observation row, so all three agree.
|
|
111
|
+
*/
|
|
112
|
+
export async function missingServedCheckout(served) {
|
|
113
|
+
const [sibling] = await vanishedServedCheckouts(served);
|
|
114
|
+
return sibling === undefined ? undefined : {
|
|
115
|
+
severity: 'error',
|
|
116
|
+
code: 'SIBLING_CHECKOUT_UNAVAILABLE',
|
|
117
|
+
message: `sibling ${sibling.slug} is recorded to be served from ${sibling.served}, which is no longer a directory`,
|
|
118
|
+
target: sibling.served,
|
|
119
|
+
fix: vanishedCheckoutFix(sibling.slug),
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
/** The directory variables Compose interpolates: each sibling's `var` and the directory that serves it. */
|
|
123
|
+
export function servedSiblingVariables(config, served) {
|
|
124
|
+
return Object.fromEntries(config.sibling.map(sibling => [sibling.var, servedPath(served, sibling.slug)]));
|
|
125
|
+
}
|
|
126
|
+
// Repository-local `core.fsmonitor` and hooks run code on nearly any Git command. Agents cannot write under
|
|
127
|
+
// `.git`, so this is defence in depth against a checkout whose configuration arrived some other way.
|
|
128
|
+
export const GIT_CHILD_CONFIG = ['-c', 'core.fsmonitor=false', '-c', 'core.hooksPath=/dev/null'];
|
|
129
|
+
// Global and system configuration can carry aliases, credential helpers and fsmonitor commands that
|
|
130
|
+
// run code; cockpit's Git children read only the repository's own configuration, as setup's clone does.
|
|
131
|
+
export async function createGitRunner(env, execute) {
|
|
132
|
+
const git = await resolveGitExecutable(env);
|
|
133
|
+
if (git.state !== 'available')
|
|
134
|
+
return { unavailable: git.reason };
|
|
135
|
+
const executable = git.value;
|
|
136
|
+
const environment = gitEnvironment(env);
|
|
137
|
+
return {
|
|
138
|
+
executable,
|
|
139
|
+
async run(cwd, id, args, { timeoutMs = 30_000, stdoutLimitBytes } = {}) {
|
|
140
|
+
const step = {
|
|
141
|
+
id, argv: [executable.canonicalPath, '-C', cwd, ...GIT_CHILD_CONFIG, ...args], argv0: executable.invocationPath,
|
|
142
|
+
cwd, env: environment, timeoutMs, stdoutLimitBytes: stdoutLimitBytes ?? 65_536, stderrLimitBytes: 65_536,
|
|
143
|
+
...stdoutLimitBytes === undefined ? { outputOverflow: 'retain-tail' } : {},
|
|
144
|
+
paths: [], ports: [], labels: { purpose: id }, expected: [],
|
|
145
|
+
consumedFiles: [{ path: executable.canonicalPath, sha256: executable.sha256, maxBytes: 536_870_912,
|
|
146
|
+
argvIndexes: [0], executable: true, executionMode: executable.executionMode }],
|
|
147
|
+
filesystemChecks: [], compensation: [],
|
|
148
|
+
};
|
|
149
|
+
const outcome = authenticatedExecutionOutcome(await execute(step), step);
|
|
150
|
+
if (!outcome)
|
|
151
|
+
return { kind: 'failed', exitCode: -1, code: 'unauthenticated', detail: 'the execution outcome could not be authenticated', stderr: '' };
|
|
152
|
+
const text = (bytes) => new TextDecoder('utf-8', { fatal: false }).decode(bytes).trim();
|
|
153
|
+
if (outcome.kind === 'complete')
|
|
154
|
+
return { kind: 'complete', stdout: text(outcome.stdout) };
|
|
155
|
+
// Git's last line names the cause (an unreachable host, a file a merge would overwrite); a
|
|
156
|
+
// remote URL in it may carry credentials, and a line that cannot be redacted is withheld.
|
|
157
|
+
const line = text(outcome.stderr).replace(/(\w+:\/\/)[^/@\s]+@/g, '$1[redacted]@').split('\n').filter(Boolean).at(-1);
|
|
158
|
+
const cause = line === undefined ? undefined : redactSensitiveMessage(line.slice(0, 240));
|
|
159
|
+
return { kind: 'failed', exitCode: outcome.execution.exitCode, code: outcome.code,
|
|
160
|
+
detail: cause ? `${outcome.code}: ${cause}` : outcome.code, stderr: text(outcome.stderr) };
|
|
161
|
+
},
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
async function checkoutIdentity(git, directory) {
|
|
165
|
+
const located = await git.run(directory, 'checkout:identity', ['rev-parse', '--path-format=absolute', '--show-toplevel', '--git-common-dir']);
|
|
166
|
+
if (located.kind !== 'complete')
|
|
167
|
+
return { reason: `it is not a Git checkout (${located.detail})` };
|
|
168
|
+
const [toplevel, commonDirectory] = located.stdout.split('\n');
|
|
169
|
+
if (!toplevel || !commonDirectory)
|
|
170
|
+
return { reason: 'Git did not name its working tree and object store' };
|
|
171
|
+
const origin = await git.run(directory, 'checkout:origin', ['config', '--get', 'remote.origin.url']);
|
|
172
|
+
const identity = origin.kind === 'complete' ? repositoryIdentity(origin.stdout) : undefined;
|
|
173
|
+
try {
|
|
174
|
+
return {
|
|
175
|
+
toplevel: await realpath(toplevel), commonDirectory: await realpath(commonDirectory),
|
|
176
|
+
origin: identity?.kind === 'known' ? identity.identity : undefined,
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
catch (error) {
|
|
180
|
+
return { reason: `the working tree or object store Git named cannot be resolved (${errorCause(error)})` };
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
function sameIdentity(left, right) {
|
|
184
|
+
return left.host === right.host && left.owner.toLowerCase() === right.owner.toLowerCase()
|
|
185
|
+
&& left.repository.toLowerCase() === right.repository.toLowerCase();
|
|
186
|
+
}
|
|
187
|
+
function spelled(identity) {
|
|
188
|
+
return identity === undefined ? 'no recognisable origin remote' : `${identity.host}/${identity.owner}/${identity.repository}`;
|
|
189
|
+
}
|
|
190
|
+
/**
|
|
191
|
+
* Whether `candidate` is the root of a checkout of the sibling's repository: it shares the declared
|
|
192
|
+
* checkout's object store (a linked worktree), or its origin names the same repository as the
|
|
193
|
+
* declared checkout's origin or the declared remote. Anything else is refused: serving an unrelated
|
|
194
|
+
* repository under a sibling's name would hand its code the sibling's mounts and grants.
|
|
195
|
+
*/
|
|
196
|
+
export async function inspectSiblingCheckout(input) {
|
|
197
|
+
const unavailable = (message, fix) => ({ kind: 'refusal', diagnostic: {
|
|
198
|
+
severity: 'error', code: 'SIBLING_CHECKOUT_UNAVAILABLE',
|
|
199
|
+
message, target: input.candidate, fix,
|
|
200
|
+
} });
|
|
201
|
+
const listWorktrees = `list the checkouts of ${input.sibling.slug} with git -C ${input.declared} worktree list, and name one of them`;
|
|
202
|
+
let directory;
|
|
203
|
+
try {
|
|
204
|
+
directory = await realpath(input.candidate);
|
|
205
|
+
if (!(await lstat(directory)).isDirectory())
|
|
206
|
+
return unavailable(`${input.candidate} is not a directory`, listWorktrees);
|
|
207
|
+
}
|
|
208
|
+
catch (error) {
|
|
209
|
+
return unavailable(`${input.candidate} cannot be resolved (${errorCause(error)})`, listWorktrees);
|
|
210
|
+
}
|
|
211
|
+
// The guard admits an agent's `use` by the path as written; a link in it, swapped in after that
|
|
212
|
+
// check, would record and then grant a directory the agent was never allowed to reach.
|
|
213
|
+
if (directory !== input.candidate) {
|
|
214
|
+
return unavailable(`${input.candidate} reaches ${directory} through a symbolic link, so cockpit will not record it`, `name the directory itself: ${cockpitCommand('use', { operand: input.sibling.slug, args: formatShellCommand([directory]) })}`);
|
|
215
|
+
}
|
|
216
|
+
const [candidate, declared] = await Promise.all([checkoutIdentity(input.git, directory), checkoutIdentity(input.git, input.declared)]);
|
|
217
|
+
if ('reason' in candidate)
|
|
218
|
+
return unavailable(`${directory} cannot serve sibling ${input.sibling.slug}: ${candidate.reason}`, listWorktrees);
|
|
219
|
+
if (candidate.toplevel !== directory) {
|
|
220
|
+
return unavailable(`${directory} is inside the checkout at ${candidate.toplevel}, not its root, so the stack would mount part of a checkout`, `name the checkout's root: ${cockpitCommand('use', { operand: input.sibling.slug, args: formatShellCommand([candidate.toplevel]) })}`);
|
|
221
|
+
}
|
|
222
|
+
if (!('reason' in declared) && declared.commonDirectory === candidate.commonDirectory) {
|
|
223
|
+
return { kind: 'same-repository', directory, evidence: 'object-store' };
|
|
224
|
+
}
|
|
225
|
+
// The declared checkout may track a fork while cockpit.toml's remote names the upstream; either counts.
|
|
226
|
+
const declaredRemote = input.sibling.remote === undefined ? undefined : repositoryIdentity(input.sibling.remote);
|
|
227
|
+
const expected = [...!('reason' in declared) && declared.origin !== undefined ? [declared.origin] : [],
|
|
228
|
+
...declaredRemote?.kind === 'known' ? [declaredRemote.identity] : []];
|
|
229
|
+
if (candidate.origin !== undefined && expected.some(identity => sameIdentity(identity, candidate.origin))) {
|
|
230
|
+
return { kind: 'same-repository', directory, evidence: 'remote' };
|
|
231
|
+
}
|
|
232
|
+
return { kind: 'refusal', diagnostic: {
|
|
233
|
+
severity: 'error', code: 'SIBLING_CHECKOUT_FOREIGN',
|
|
234
|
+
message: `${directory} is not a checkout of sibling ${input.sibling.slug}: it shares no object store with ${input.declared}, `
|
|
235
|
+
+ `and its origin (${spelled(candidate.origin)}) is not the sibling's (${expected.length === 0 ? 'none known' : expected.map(spelled).join(' or ')})`,
|
|
236
|
+
target: input.candidate,
|
|
237
|
+
fix: `create a worktree of the sibling with git -C ${input.declared} worktree add <path> <branch> and name that path, or ${listWorktrees}`,
|
|
238
|
+
} };
|
|
239
|
+
}
|
|
240
|
+
/** The declared environment files the served checkout lacks; a fresh worktree has none of the ignored ones. */
|
|
241
|
+
export async function missingEnvironmentFiles(served, sibling) {
|
|
242
|
+
const present = await Promise.all((sibling.environment_files ?? []).map(async (file) => await lstatIfPresent(join(served.served, file.instance)) !== undefined));
|
|
243
|
+
return (sibling.environment_files ?? []).filter((_, index) => !present[index]).map(file => ({ template: file.template, instance: file.instance }));
|
|
244
|
+
}
|
|
245
|
+
/** One warning per environment file an overriding checkout lacks, naming who must create it and from what. */
|
|
246
|
+
export function missingEnvironmentFileDiagnostics(served, missing) {
|
|
247
|
+
return missing.map(file => ({
|
|
248
|
+
severity: 'warning',
|
|
249
|
+
code: 'SIBLING_ENVIRONMENT_FILE_MISSING',
|
|
250
|
+
message: `sibling ${served.slug} is served from ${served.served}, which has no ${file.instance}; the services that read it would start without its settings`,
|
|
251
|
+
target: join(served.served, file.instance),
|
|
252
|
+
fix: `a person creates ${join(served.served, file.instance)} from the template ${join(served.served, file.template)}, or copies the one in ${served.declared}; cockpit never copies environment files between checkouts`,
|
|
253
|
+
}));
|
|
254
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { CockpitConfig } from '../schema.js';
|
|
2
|
+
import { type EnvironmentObservation, type StackId } from './contract.js';
|
|
3
|
+
export declare const observationDeclarations: readonly [{
|
|
4
|
+
readonly id: 'served-checkouts';
|
|
5
|
+
readonly owner: "sibling-observation";
|
|
6
|
+
readonly postures: readonly ["environment"];
|
|
7
|
+
readonly stages: readonly [{
|
|
8
|
+
readonly stage: 'bootstrap';
|
|
9
|
+
readonly disposition: 'required';
|
|
10
|
+
}, {
|
|
11
|
+
readonly stage: 'steady';
|
|
12
|
+
readonly disposition: 'required';
|
|
13
|
+
}];
|
|
14
|
+
readonly code: "SIBLING_CHECKOUT_UNAVAILABLE";
|
|
15
|
+
readonly isIssued: typeof isSiblingObservation;
|
|
16
|
+
readonly issuedAt: typeof observationIssuedAt;
|
|
17
|
+
}];
|
|
18
|
+
declare function isSiblingObservation(value: unknown): value is EnvironmentObservation;
|
|
19
|
+
declare function observationIssuedAt(value: unknown): number | undefined;
|
|
20
|
+
/** One row: the directories that should serve a sibling and do not exist. Passing means none. */
|
|
21
|
+
export declare function observeServedCheckouts(input: Readonly<{
|
|
22
|
+
stackId: StackId;
|
|
23
|
+
observedAt: string;
|
|
24
|
+
configPath: string;
|
|
25
|
+
config: Pick<CockpitConfig, 'cockpit' | 'sibling'>;
|
|
26
|
+
}>): Promise<readonly EnvironmentObservation[]>;
|
|
27
|
+
export {};
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// Owns: observing whether each checkout recorded to serve a sibling still exists. It is the one
|
|
2
|
+
// verdict preflight, doctor and `up` share: `up` refuses a vanished served checkout, so a gate that
|
|
3
|
+
// only warned about it would pass a stack `up` cannot start. Must not: decide the health verdict
|
|
4
|
+
// (observations.ts) or inspect whether a checkout belongs to its sibling (sibling-checkouts.ts).
|
|
5
|
+
import { dirname } from 'node:path';
|
|
6
|
+
import { ENVIRONMENT_CODES, RefusalError, snapshotEnvironmentObservation, } from './contract.js';
|
|
7
|
+
import { loadServedSiblings, SIBLING_CHECKOUTS_PATH, vanishedCheckoutFix, vanishedServedCheckouts } from './sibling-checkouts.js';
|
|
8
|
+
const OWNER = 'sibling-observation';
|
|
9
|
+
export const observationDeclarations = Object.freeze([
|
|
10
|
+
{ id: 'served-checkouts', owner: OWNER, postures: ['environment'], stages: [{ stage: 'bootstrap', disposition: 'required' }, { stage: 'steady', disposition: 'required' }], code: ENVIRONMENT_CODES.SIBLING_CHECKOUT_UNAVAILABLE, isIssued: isSiblingObservation, issuedAt: observationIssuedAt },
|
|
11
|
+
]);
|
|
12
|
+
const issuedObservations = new WeakMap();
|
|
13
|
+
function isSiblingObservation(value) {
|
|
14
|
+
return value !== null && typeof value === 'object' && issuedObservations.has(value);
|
|
15
|
+
}
|
|
16
|
+
function observationIssuedAt(value) {
|
|
17
|
+
return value !== null && typeof value === 'object' ? issuedObservations.get(value) : undefined;
|
|
18
|
+
}
|
|
19
|
+
/** One row: the directories that should serve a sibling and do not exist. Passing means none. */
|
|
20
|
+
export async function observeServedCheckouts(input) {
|
|
21
|
+
const served = await loadServedSiblings(dirname(input.configPath), input.config);
|
|
22
|
+
let missing;
|
|
23
|
+
let remedy;
|
|
24
|
+
if (served.kind === 'refusal') {
|
|
25
|
+
// `up` refuses an unreadable record as well: no one can say which checkout serves a sibling.
|
|
26
|
+
missing = [SIBLING_CHECKOUTS_PATH];
|
|
27
|
+
remedy = served.diagnostic.fix ?? `repair ${SIBLING_CHECKOUTS_PATH}`;
|
|
28
|
+
}
|
|
29
|
+
else {
|
|
30
|
+
const vanished = await vanishedServedCheckouts(served.siblings);
|
|
31
|
+
missing = vanished.map(sibling => sibling.served);
|
|
32
|
+
remedy = vanished.length === 0 ? 'no action needed' : vanished.map(sibling => `${sibling.slug}: ${vanishedCheckoutFix(sibling.slug)}`).join('; ');
|
|
33
|
+
}
|
|
34
|
+
const snapshot = snapshotEnvironmentObservation({
|
|
35
|
+
code: ENVIRONMENT_CODES.SIBLING_CHECKOUT_UNAVAILABLE, owner: OWNER, stackId: input.stackId, source: 'served-checkouts',
|
|
36
|
+
observed: { state: 'available', value: missing, observedAt: input.observedAt },
|
|
37
|
+
expectation: { kind: 'equal', value: [] },
|
|
38
|
+
applicability: { state: 'required', posture: 'environment' },
|
|
39
|
+
freshness: { state: 'current' }, remedy,
|
|
40
|
+
});
|
|
41
|
+
if (!snapshot)
|
|
42
|
+
throw new RefusalError('served-checkouts observation is malformed');
|
|
43
|
+
issuedObservations.set(snapshot, Date.now());
|
|
44
|
+
return [snapshot];
|
|
45
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { GitRunner } from './sibling-checkouts.js';
|
|
2
|
+
export type PullRow = Readonly<{
|
|
3
|
+
state: 'advanced';
|
|
4
|
+
path: string;
|
|
5
|
+
branch: string;
|
|
6
|
+
upstream: string;
|
|
7
|
+
from: string;
|
|
8
|
+
to: string;
|
|
9
|
+
commits: number;
|
|
10
|
+
}> | Readonly<{
|
|
11
|
+
state: 'current';
|
|
12
|
+
path: string;
|
|
13
|
+
branch: string;
|
|
14
|
+
upstream: string;
|
|
15
|
+
ahead: number;
|
|
16
|
+
}> | Readonly<{
|
|
17
|
+
state: 'skipped';
|
|
18
|
+
path: string;
|
|
19
|
+
reason: string;
|
|
20
|
+
fix: string;
|
|
21
|
+
blocked: boolean;
|
|
22
|
+
}>;
|
|
23
|
+
export declare function fastForwardCheckout(git: GitRunner, path: string): Promise<PullRow>;
|