@ours.network/fleet 0.18.0-nightly.6 → 0.18.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/README.md +111 -43
- package/dist/application/fleet-query-service.js +12 -0
- package/dist/application/role-creation-service.js +3 -1
- package/dist/application/types.d.ts +11 -0
- package/dist/briefing.js +9 -2
- package/dist/build-info.json +7 -6
- package/dist/capabilities.d.ts +3 -1
- package/dist/capabilities.js +3 -0
- package/dist/cli.js +83 -7
- package/dist/config.d.ts +11 -3
- package/dist/config.js +40 -15
- package/dist/creation.d.ts +14 -15
- package/dist/creation.js +19 -13
- package/dist/docs.d.ts +1 -1
- package/dist/docs.js +117 -35
- package/dist/doctor.d.ts +1 -5
- package/dist/doctor.js +11 -18
- package/dist/fleet-proxy.d.ts +5 -0
- package/dist/harness/acp-agent.js +11 -6
- package/dist/harness/claude-code.js +204 -11
- package/dist/harness/codex.d.ts +4 -1
- package/dist/harness/codex.js +74 -12
- package/dist/harness/types.d.ts +54 -4
- package/dist/harness-plugins.d.ts +48 -0
- package/dist/harness-plugins.js +309 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1 -0
- package/dist/loops/manager.d.ts +30 -1
- package/dist/loops/manager.js +69 -6
- package/dist/loops/state.d.ts +18 -0
- package/dist/loops/state.js +4 -0
- package/dist/model-env.d.ts +71 -0
- package/dist/model-env.js +106 -0
- package/dist/monitor.js +1 -1
- package/dist/ops.js +1 -1
- package/dist/owner-channel/attachments.d.ts +2 -25
- package/dist/owner-channel/attachments.js +5 -61
- package/dist/owner-channel/channel.d.ts +30 -29
- package/dist/owner-channel/channel.js +291 -291
- package/dist/owner-channel/mcp.d.ts +24 -0
- package/dist/owner-channel/mcp.js +145 -0
- package/dist/owner-channel/notices.d.ts +7 -0
- package/dist/owner-channel/notices.js +9 -0
- package/dist/resolved-plan.js +1 -0
- package/dist/runner.d.ts +48 -0
- package/dist/runner.js +237 -85
- package/dist/session/acp.d.ts +104 -0
- package/dist/session/acp.js +213 -10
- package/dist/session/activity.d.ts +31 -0
- package/dist/session/activity.js +48 -0
- package/dist/session/conversation-normalizer.d.ts +6 -0
- package/dist/session/conversation-normalizer.js +153 -10
- package/dist/session/conversation-types.d.ts +23 -4
- package/dist/session/types.d.ts +35 -0
- package/dist/spawn.js +29 -17
- package/dist/supervisor/systemd.js +2 -29
- package/dist/watchdog/briefing.js +7 -0
- package/dist/web-app/assets/{TerminalView-BAVk1Bot.js → TerminalView-C_G1ID2P.js} +1 -1
- package/dist/web-app/assets/{index-C3S-xFRU.js → index-BCBK78hw.js} +5 -5
- package/dist/web-app/index.html +1 -1
- package/dist/worklog.d.ts +7 -1
- package/dist/worklog.js +191 -39
- package/package.json +1 -3
- package/dist/owner-channel/message-recovery.d.ts +0 -25
- package/dist/owner-channel/message-recovery.js +0 -114
- package/dist/owner-channel/ours-client.d.ts +0 -148
- package/dist/owner-channel/ours-client.js +0 -231
package/dist/harness/codex.js
CHANGED
|
@@ -5,7 +5,8 @@ import { agentDir, home } from '../paths.js';
|
|
|
5
5
|
import { realExec } from '../exec.js';
|
|
6
6
|
import { registerAdapter } from './registry.js';
|
|
7
7
|
import { harnessRuntimeDir } from '../isolation/policy.js';
|
|
8
|
-
import {
|
|
8
|
+
import { resolveBundledAcpAgent, } from './acp-agent.js';
|
|
9
|
+
import { restoreLockedHarnessMarketplace } from '../harness-plugins.js';
|
|
9
10
|
const OPTION_KEYS = [
|
|
10
11
|
'launcher', 'sandbox', 'approval', 'permission_mode', 'search', 'profile', 'config', 'add_dirs',
|
|
11
12
|
'monitor',
|
|
@@ -51,9 +52,7 @@ function sandboxMode(role) {
|
|
|
51
52
|
throw new Error(`invalid harness_options.sandbox "${s}"; allowed: ${SANDBOX_MODES.join(', ')}`);
|
|
52
53
|
return s;
|
|
53
54
|
}
|
|
54
|
-
|
|
55
|
-
function acpAgentMode(role) {
|
|
56
|
-
const sandbox = sandboxMode(role);
|
|
55
|
+
function modeForSandbox(sandbox) {
|
|
57
56
|
if (sandbox === 'read-only')
|
|
58
57
|
return 'read-only';
|
|
59
58
|
if (sandbox === 'workspace-write')
|
|
@@ -62,6 +61,26 @@ function acpAgentMode(role) {
|
|
|
62
61
|
return 'agent-full-access';
|
|
63
62
|
return undefined;
|
|
64
63
|
}
|
|
64
|
+
/**
|
|
65
|
+
* Resolve the coupled Codex ACP mode.
|
|
66
|
+
*
|
|
67
|
+
* The portable approval contract owns the default mode selection: `allow`
|
|
68
|
+
* means the adapter's fully non-interactive yolo preset and `auto` means its
|
|
69
|
+
* ordinary agent preset. This intentionally means that Codex ACP cannot retain
|
|
70
|
+
* an independent neutral filesystem posture for those two modes. An explicit
|
|
71
|
+
* native sandbox remains authoritative and selects its corresponding preset.
|
|
72
|
+
*/
|
|
73
|
+
function acpAgentMode(role) {
|
|
74
|
+
const explicitSandbox = role.harness_options?.sandbox;
|
|
75
|
+
if (explicitSandbox != null)
|
|
76
|
+
return modeForSandbox(sandboxMode(role));
|
|
77
|
+
if (role.permissions?.approval === 'allow')
|
|
78
|
+
return 'agent-full-access';
|
|
79
|
+
if (role.permissions?.approval === 'auto')
|
|
80
|
+
return 'agent';
|
|
81
|
+
const sandbox = sandboxMode(role);
|
|
82
|
+
return modeForSandbox(sandbox);
|
|
83
|
+
}
|
|
65
84
|
function acpModePermissions(mode) {
|
|
66
85
|
if (mode === 'read-only')
|
|
67
86
|
return { approval: 'on-request', sandbox: 'read-only' };
|
|
@@ -69,6 +88,10 @@ function acpModePermissions(mode) {
|
|
|
69
88
|
return { approval: 'never', sandbox: 'danger-full-access' };
|
|
70
89
|
return { approval: 'on-request', sandbox: 'workspace-write' };
|
|
71
90
|
}
|
|
91
|
+
/** The sandbox Codex will actually receive from the selected coupled ACP mode. */
|
|
92
|
+
function acpRuntimeSandbox(role) {
|
|
93
|
+
return acpModePermissions(acpAgentMode(role)).sandbox;
|
|
94
|
+
}
|
|
72
95
|
function fleetModeForApproval(nativeMode) {
|
|
73
96
|
if (nativeMode === 'never')
|
|
74
97
|
return 'allow';
|
|
@@ -102,6 +125,18 @@ function launcherMode(role) {
|
|
|
102
125
|
function bundledCodexAcp() {
|
|
103
126
|
return resolveBundledAcpAgent(CODEX_ACP_PACKAGE, 'codex-acp', 'codex-acp');
|
|
104
127
|
}
|
|
128
|
+
/** Bind launch argv and metadata provenance to one already-completed resolution. */
|
|
129
|
+
export function codexAcpLaunchForResolution(resolution) {
|
|
130
|
+
const permissionMetadataSource = resolution.bundled
|
|
131
|
+
&& resolution.version === BUNDLED_CODEX_ACP_VERSION
|
|
132
|
+
&& resolution.manifestPath !== undefined
|
|
133
|
+
? 'codex-acp'
|
|
134
|
+
: undefined;
|
|
135
|
+
return {
|
|
136
|
+
argv: [...resolution.argv],
|
|
137
|
+
...(permissionMetadataSource ? { permissionMetadataSource } : {}),
|
|
138
|
+
};
|
|
139
|
+
}
|
|
105
140
|
function canOverrideBundledAcpApproval() {
|
|
106
141
|
const resolution = bundledCodexAcp();
|
|
107
142
|
return resolution.bundled && resolution.version === BUNDLED_CODEX_ACP_VERSION
|
|
@@ -147,7 +182,7 @@ function codexAcpEnvironment(role, dirs) {
|
|
|
147
182
|
return {
|
|
148
183
|
CODEX_PATH: command,
|
|
149
184
|
[CODEX_PROXY_APPROVAL_ENV]: approvalPolicy(role) ?? 'on-request',
|
|
150
|
-
[CODEX_PROXY_SANDBOX_ENV]:
|
|
185
|
+
[CODEX_PROXY_SANDBOX_ENV]: acpRuntimeSandbox(role),
|
|
151
186
|
[CODEX_PROXY_MANIFEST_ENV]: resolution.manifestPath,
|
|
152
187
|
...(process.env.CODEX_PATH ? { [CODEX_PROXY_REAL_PATH_ENV]: process.env.CODEX_PATH } : {}),
|
|
153
188
|
};
|
|
@@ -275,6 +310,9 @@ export function makeCodexAdapter(exec = realExec) {
|
|
|
275
310
|
return errs;
|
|
276
311
|
},
|
|
277
312
|
async prepareSession(role, dirs) {
|
|
313
|
+
// Re-materialize only from the persisted exact lock. Ordinary launches
|
|
314
|
+
// never resolve npm tags or run an installer.
|
|
315
|
+
restoreLockedHarnessMarketplace('codex', role.harnessPluginChannel);
|
|
278
316
|
// Per-role harness runtime home (5.1); harmless for un-isolated roles.
|
|
279
317
|
// Only a role that declares `isolation:` gets a sandbox, and only a
|
|
280
318
|
// sandbox needs this directory to exist before entry.
|
|
@@ -285,7 +323,16 @@ export function makeCodexAdapter(exec = realExec) {
|
|
|
285
323
|
if (requested === 'ours-codex' && !hasOursCodex)
|
|
286
324
|
throw new Error('harness_options.launcher is ours-codex, but ours-codex is not on PATH; install @ours.network/codex or use launcher: auto');
|
|
287
325
|
const command = requested === 'codex' ? 'codex' : hasOursCodex ? 'ours-codex' : 'codex';
|
|
288
|
-
return {
|
|
326
|
+
return {
|
|
327
|
+
argv: [],
|
|
328
|
+
// OURS_BIND_IDENTITY is the connector's startup bind seed — see the note in
|
|
329
|
+
// claude-code.ts's prepareSession. It belongs on EVERY harness that runs a
|
|
330
|
+
// role with an ours identity, not just claude-code: a seed that works on one
|
|
331
|
+
// harness and silently does nothing on the other is the same class of defect
|
|
332
|
+
// as a config key that only works on one session type.
|
|
333
|
+
env: { OURS_BIND_IDENTITY: role.identity, ...codexAcpEnvironment(role, dirs) },
|
|
334
|
+
command,
|
|
335
|
+
};
|
|
289
336
|
},
|
|
290
337
|
buildLaunch(role, mode, _s, prep) {
|
|
291
338
|
const stateDir = roleStateDir(role);
|
|
@@ -299,17 +346,30 @@ export function makeCodexAdapter(exec = realExec) {
|
|
|
299
346
|
},
|
|
300
347
|
buildAcpLaunch(role, prep) {
|
|
301
348
|
const configured = role.session_options?.acp?.command;
|
|
349
|
+
// Resolve once: both argv and permission-metadata provenance must describe
|
|
350
|
+
// the same artifact. A bare PATH fallback is launchable for compatibility,
|
|
351
|
+
// but is never authenticated for protected-MCP auto-approval.
|
|
352
|
+
const resolved = configured == null
|
|
353
|
+
? codexAcpLaunchForResolution(bundledCodexAcp())
|
|
354
|
+
: undefined;
|
|
302
355
|
const argv = Array.isArray(configured)
|
|
303
356
|
? [...configured]
|
|
304
357
|
: typeof configured === 'string'
|
|
305
358
|
? ['sh', '-c', configured]
|
|
306
|
-
:
|
|
359
|
+
: resolved.argv;
|
|
307
360
|
const initialMode = acpAgentMode(role);
|
|
308
361
|
return {
|
|
309
362
|
argv,
|
|
310
363
|
env: initialMode ? { ...prep.env, INITIAL_AGENT_MODE: initialMode } : prep.env,
|
|
364
|
+
...(resolved?.permissionMetadataSource
|
|
365
|
+
? { permissionMetadataSource: resolved.permissionMetadataSource } : {}),
|
|
311
366
|
};
|
|
312
367
|
},
|
|
368
|
+
// INITIAL_AGENT_MODE covers session/new in codex-acp; session/set_mode
|
|
369
|
+
// keeps resumed/loaded sessions and live status on the identical mode.
|
|
370
|
+
acpPermissionModeId(role) {
|
|
371
|
+
return acpAgentMode(role);
|
|
372
|
+
},
|
|
313
373
|
isolationPaths(role, _dirs) {
|
|
314
374
|
const codexHome = join(home(), '.codex');
|
|
315
375
|
const profile = role.harness_options?.profile;
|
|
@@ -362,7 +422,9 @@ export function makeCodexAdapter(exec = realExec) {
|
|
|
362
422
|
const mode = acpAgentMode(role) ?? 'agent';
|
|
363
423
|
const configured = role.session_options?.acp?.command;
|
|
364
424
|
const overrideAvailable = configured == null && canOverrideBundledAcpApproval();
|
|
365
|
-
const actual = overrideAvailable
|
|
425
|
+
const actual = overrideAvailable
|
|
426
|
+
? { approval, sandbox: acpRuntimeSandbox(role) }
|
|
427
|
+
: acpModePermissions(mode);
|
|
366
428
|
const exact = actual.approval === approval && actual.sandbox === sandbox;
|
|
367
429
|
return {
|
|
368
430
|
...translated,
|
|
@@ -372,10 +434,9 @@ export function makeCodexAdapter(exec = realExec) {
|
|
|
372
434
|
? `custom ACP command cannot be verified against approval=${approval} sandbox=${sandbox}; `
|
|
373
435
|
+ `its '${mode}' mode is conservatively treated as approval=${actual.approval} `
|
|
374
436
|
+ `sandbox=${actual.sandbox}`
|
|
375
|
-
: `
|
|
376
|
-
+ `sandbox=${actual.sandbox}
|
|
377
|
-
+ `
|
|
378
|
-
+ `approval=${approval} sandbox=${sandbox}`],
|
|
437
|
+
: `Codex ACP mode '${mode}' couples approval and filesystem as `
|
|
438
|
+
+ `approval=${actual.approval} sandbox=${actual.sandbox}; this does not exactly `
|
|
439
|
+
+ `represent approval=${approval} sandbox=${sandbox}`],
|
|
379
440
|
capabilities: codexCapabilities(actual.approval, actual.sandbox),
|
|
380
441
|
};
|
|
381
442
|
}
|
|
@@ -411,6 +472,7 @@ export function makeCodexAdapter(exec = realExec) {
|
|
|
411
472
|
currentIdentityTool: 'current_identity',
|
|
412
473
|
sendTool: 'send_message',
|
|
413
474
|
getMessagesTool: 'get_messages',
|
|
475
|
+
watchCommand: id => `ours-mcp watch "${id}"`,
|
|
414
476
|
monitorInstruction: (id, configuredRole) => {
|
|
415
477
|
const consented = configuredRole?.harness_options?.monitor === true;
|
|
416
478
|
const consent = consented
|
package/dist/harness/types.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { McpServer } from '@agentclientprotocol/sdk';
|
|
1
2
|
import type { CommonPermissions, FleetPermissionMode, ResolvedRole } from '../config.js';
|
|
2
3
|
export interface PrereqCheck {
|
|
3
4
|
name: string;
|
|
@@ -21,14 +22,39 @@ export interface SessionPrep {
|
|
|
21
22
|
env: Record<string, string>;
|
|
22
23
|
/** Optional launcher selected after runtime prerequisite probing. */
|
|
23
24
|
command?: string;
|
|
25
|
+
/**
|
|
26
|
+
* The settings overlay prepareSession wrote, if it wrote one.
|
|
27
|
+
*
|
|
28
|
+
* The tmux launch delivers this as `--settings <path>` in `argv`; an ACP agent
|
|
29
|
+
* takes no flags, so it needs the PATH rather than the flag. Recorded here so
|
|
30
|
+
* the two deliveries read one value instead of each re-deriving the filename.
|
|
31
|
+
*/
|
|
32
|
+
settingsOverlay?: string;
|
|
33
|
+
/**
|
|
34
|
+
* The MCP config file prepareSession wrote for `harness_options.mcp_servers`,
|
|
35
|
+
* if the role declared any. Same reason as `settingsOverlay`: the tmux launch
|
|
36
|
+
* passes the file, the ACP launch has to send the servers themselves.
|
|
37
|
+
*/
|
|
38
|
+
mcpConfigFile?: string;
|
|
24
39
|
}
|
|
40
|
+
/**
|
|
41
|
+
* One MCP server as ACP's `session/new` declares it.
|
|
42
|
+
*
|
|
43
|
+
* ⚠ THE PROTOCOL'S OWN TYPE, DELIBERATELY NOT A LOCAL RESTATEMENT. `mcpServers`
|
|
44
|
+
* goes onto the wire unchanged, so a hand-written near-copy would compile while
|
|
45
|
+
* being subtly wrong — `env` and `headers` are REQUIRED arrays, and the stdio
|
|
46
|
+
* variant is the one with no `type` field at all. Aliasing it also keeps
|
|
47
|
+
* `session/new`'s response type inferable, which a structural stand-in silently
|
|
48
|
+
* broke (every field of the result degraded to `unknown`).
|
|
49
|
+
*/
|
|
50
|
+
export type AcpMcpServer = McpServer;
|
|
25
51
|
export interface Launch {
|
|
26
52
|
argv: string[];
|
|
27
53
|
env: Record<string, string>;
|
|
28
54
|
}
|
|
29
|
-
export interface AcpLaunch {
|
|
30
|
-
argv
|
|
31
|
-
|
|
55
|
+
export interface AcpLaunch extends Launch {
|
|
56
|
+
/** Metadata vocabulary authenticated by the exact ACP artifact in argv. */
|
|
57
|
+
permissionMetadataSource?: 'codex-acp';
|
|
32
58
|
}
|
|
33
59
|
/**
|
|
34
60
|
* The result of expressing neutral `permissions:` in a harness's own terms.
|
|
@@ -67,6 +93,7 @@ export interface BriefingVocab {
|
|
|
67
93
|
currentIdentityTool: string;
|
|
68
94
|
sendTool: string;
|
|
69
95
|
getMessagesTool: string;
|
|
96
|
+
watchCommand(identity: string): string;
|
|
70
97
|
monitorInstruction(identity: string, role?: ResolvedRole): string;
|
|
71
98
|
/** Wake-source wording for a role whose monitor is supervisor-owned (monitor.mode=fleet). */
|
|
72
99
|
supervisedWakeNote(identity: string, role?: ResolvedRole): string;
|
|
@@ -95,7 +122,13 @@ export interface HarnessAdapter {
|
|
|
95
122
|
id: string;
|
|
96
123
|
supportsResume: boolean;
|
|
97
124
|
checkPrereqs(): Promise<PrereqReport>;
|
|
98
|
-
|
|
125
|
+
/**
|
|
126
|
+
* `role` is the SESSION-AWARE half: some harness options can only be honoured
|
|
127
|
+
* on some session types, and an option that is silently dropped is worse than
|
|
128
|
+
* one that is refused. Optional so an adapter that has nothing session-specific
|
|
129
|
+
* to say keeps its one-argument implementation.
|
|
130
|
+
*/
|
|
131
|
+
validateOptions(opts: unknown, role?: ResolvedRole): ValidationError[];
|
|
99
132
|
prepareSession(role: ResolvedRole, dirs: RoleDirs): Promise<SessionPrep>;
|
|
100
133
|
buildLaunch(role: ResolvedRole, mode: 'fresh' | 'resume', s: SessionState, prep: SessionPrep): Launch;
|
|
101
134
|
buildAcpLaunch?(role: ResolvedRole, prep: SessionPrep): AcpLaunch;
|
|
@@ -105,6 +138,23 @@ export interface HarnessAdapter {
|
|
|
105
138
|
* agent's default. Omit for a harness whose ACP agent has no modes.
|
|
106
139
|
*/
|
|
107
140
|
acpPermissionModeId?(role: ResolvedRole): string | undefined;
|
|
141
|
+
/**
|
|
142
|
+
* The MCP servers this role declares, for the `mcpServers` array of ACP's
|
|
143
|
+
* `session/new` / `resume` / `load`. Empty (or omitted) leaves the agent's own
|
|
144
|
+
* configuration alone, which is what fleet has always sent.
|
|
145
|
+
*/
|
|
146
|
+
acpMcpServers?(role: ResolvedRole): AcpMcpServer[];
|
|
147
|
+
/**
|
|
148
|
+
* Agent-specific `_meta` for `session/new` — how a capability the CLI takes as
|
|
149
|
+
* a flag reaches an ACP agent that accepts no flags.
|
|
150
|
+
*
|
|
151
|
+
* ⚠ THIS IS A PER-AGENT VOCABULARY, NOT PROTOCOL. `_meta` is free-form in ACP,
|
|
152
|
+
* so what an adapter puts here is only honoured by the agent it was written
|
|
153
|
+
* for. An adapter must therefore return nothing for an ACP command it did not
|
|
154
|
+
* choose, and the options that depend on it must be refused at validation for
|
|
155
|
+
* such a role rather than sent and silently ignored.
|
|
156
|
+
*/
|
|
157
|
+
acpSessionMeta?(role: ResolvedRole, prep: SessionPrep): Record<string, unknown> | undefined;
|
|
108
158
|
/** Effective portable policy and harness-native approval mode after native overrides win. */
|
|
109
159
|
effectivePermissionMode?(role: ResolvedRole): {
|
|
110
160
|
fleetMode: FleetPermissionMode;
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { type Exec } from './exec.js';
|
|
2
|
+
export declare const HARNESS_PLUGIN_IDS: readonly ["codex", "claude-code"];
|
|
3
|
+
export type HarnessPluginId = (typeof HARNESS_PLUGIN_IDS)[number];
|
|
4
|
+
export type HarnessPluginChannel = 'stable' | 'nightly';
|
|
5
|
+
export interface HarnessPluginConfig {
|
|
6
|
+
plugin_channel: HarnessPluginChannel;
|
|
7
|
+
}
|
|
8
|
+
export type HarnessPluginConfigs = Record<HarnessPluginId, HarnessPluginConfig>;
|
|
9
|
+
export interface HarnessPluginLock {
|
|
10
|
+
schemaVersion: 1;
|
|
11
|
+
harness: HarnessPluginId;
|
|
12
|
+
channel: HarnessPluginChannel;
|
|
13
|
+
distTag: 'latest' | 'nightly';
|
|
14
|
+
package: string;
|
|
15
|
+
version: string;
|
|
16
|
+
registry: 'https://registry.npmjs.org';
|
|
17
|
+
resolvedAt: string;
|
|
18
|
+
}
|
|
19
|
+
export interface HarnessPluginInstallResult {
|
|
20
|
+
lock: HarnessPluginLock;
|
|
21
|
+
lockPath: string;
|
|
22
|
+
marketplacePath: string;
|
|
23
|
+
resolved: boolean;
|
|
24
|
+
}
|
|
25
|
+
export declare function resolveHarnessPluginConfigs(raw: unknown, file?: string): HarnessPluginConfigs;
|
|
26
|
+
export declare const harnessPluginRoot: (harness: HarnessPluginId) => string;
|
|
27
|
+
export declare const harnessPluginLockPath: (harness: HarnessPluginId) => string;
|
|
28
|
+
export declare const harnessPluginMarketplaceRoot: (harness: HarnessPluginId) => string;
|
|
29
|
+
export declare const harnessPluginMarketplacePath: (harness: HarnessPluginId) => string;
|
|
30
|
+
export declare function readHarnessPluginLock(harness: HarnessPluginId): HarnessPluginLock | undefined;
|
|
31
|
+
/**
|
|
32
|
+
* Re-materialize the generated marketplace from the persisted exact lock.
|
|
33
|
+
* This is the ONLY ordinary startup/reconciliation path: it performs no exec,
|
|
34
|
+
* no network call, and cannot observe a moving npm dist-tag.
|
|
35
|
+
*/
|
|
36
|
+
export declare function restoreLockedHarnessMarketplace(harness: HarnessPluginId, expectedChannel?: HarnessPluginChannel): HarnessPluginLock | undefined;
|
|
37
|
+
/**
|
|
38
|
+
* Explicit install/update transaction.
|
|
39
|
+
*
|
|
40
|
+
* install reuses an existing same-channel lock (deterministic repair/reinstall);
|
|
41
|
+
* update always resolves the requested channel once and advances the lock.
|
|
42
|
+
*/
|
|
43
|
+
export declare function installHarnessPlugin(harness: HarnessPluginId, channel: HarnessPluginChannel, options?: {
|
|
44
|
+
update?: boolean;
|
|
45
|
+
exec?: Exec;
|
|
46
|
+
now?: () => Date;
|
|
47
|
+
}): Promise<HarnessPluginInstallResult>;
|
|
48
|
+
export declare function selectedHarnessPluginIds(values: string[]): HarnessPluginId[];
|
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { join, resolve } from 'node:path';
|
|
3
|
+
import { replaceFileAtomically, withFileLock } from './atomic-file.js';
|
|
4
|
+
import { realExec } from './exec.js';
|
|
5
|
+
import { stateRoot } from './paths.js';
|
|
6
|
+
export const HARNESS_PLUGIN_IDS = ['codex', 'claude-code'];
|
|
7
|
+
const REGISTRY = 'https://registry.npmjs.org';
|
|
8
|
+
const SPECS = {
|
|
9
|
+
codex: {
|
|
10
|
+
package: '@ours.network/codex',
|
|
11
|
+
marketplaceName: 'ours-fleet-codex-lock',
|
|
12
|
+
marketplaceManifest: '.agents/plugins/marketplace.json',
|
|
13
|
+
executable: 'codex',
|
|
14
|
+
},
|
|
15
|
+
'claude-code': {
|
|
16
|
+
package: '@ours.network/claude-code',
|
|
17
|
+
marketplaceName: 'ours-fleet-claude-lock',
|
|
18
|
+
marketplaceManifest: '.claude-plugin/marketplace.json',
|
|
19
|
+
executable: 'claude',
|
|
20
|
+
},
|
|
21
|
+
};
|
|
22
|
+
const EXACT_SEMVER = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/;
|
|
23
|
+
const isRecord = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
24
|
+
export function resolveHarnessPluginConfigs(raw, file = 'fleet.yaml') {
|
|
25
|
+
const resolved = {
|
|
26
|
+
codex: { plugin_channel: 'stable' },
|
|
27
|
+
'claude-code': { plugin_channel: 'stable' },
|
|
28
|
+
};
|
|
29
|
+
if (raw === undefined)
|
|
30
|
+
return resolved;
|
|
31
|
+
if (!isRecord(raw))
|
|
32
|
+
throw new Error(`${file}: harnesses must be a map`);
|
|
33
|
+
const unknown = Object.keys(raw).filter(key => !HARNESS_PLUGIN_IDS.includes(key));
|
|
34
|
+
if (unknown.length)
|
|
35
|
+
throw new Error(`${file}: harnesses has unknown harness(es) ${unknown.join(', ')}; allowed: ${HARNESS_PLUGIN_IDS.join(', ')}`);
|
|
36
|
+
for (const harness of HARNESS_PLUGIN_IDS) {
|
|
37
|
+
const value = raw[harness];
|
|
38
|
+
if (value === undefined)
|
|
39
|
+
continue;
|
|
40
|
+
if (!isRecord(value))
|
|
41
|
+
throw new Error(`${file}: harnesses.${harness} must be a map`);
|
|
42
|
+
const bad = Object.keys(value).filter(key => key !== 'plugin_channel');
|
|
43
|
+
if (bad.length)
|
|
44
|
+
throw new Error(`${file}: harnesses.${harness} has unknown key(s) ${bad.join(', ')}; allowed: plugin_channel`);
|
|
45
|
+
const channel = value.plugin_channel;
|
|
46
|
+
if (channel !== 'stable' && channel !== 'nightly')
|
|
47
|
+
throw new Error(`${file}: harnesses.${harness}.plugin_channel must be one of: stable, nightly`);
|
|
48
|
+
resolved[harness] = { plugin_channel: channel };
|
|
49
|
+
}
|
|
50
|
+
return resolved;
|
|
51
|
+
}
|
|
52
|
+
export const harnessPluginRoot = (harness) => join(stateRoot(), 'harness-plugins', harness);
|
|
53
|
+
export const harnessPluginLockPath = (harness) => join(harnessPluginRoot(harness), 'plugin-lock.json');
|
|
54
|
+
export const harnessPluginMarketplaceRoot = (harness) => join(harnessPluginRoot(harness), 'marketplace');
|
|
55
|
+
export const harnessPluginMarketplacePath = (harness) => join(harnessPluginMarketplaceRoot(harness), SPECS[harness].marketplaceManifest);
|
|
56
|
+
function validateLock(value, harness, path) {
|
|
57
|
+
const spec = SPECS[harness];
|
|
58
|
+
const fail = (detail) => {
|
|
59
|
+
throw new Error(`invalid harness plugin lock ${path}: ${detail}; run \`ours-fleet plugins update ${harness}\``);
|
|
60
|
+
};
|
|
61
|
+
if (!isRecord(value))
|
|
62
|
+
return fail('expected a JSON object');
|
|
63
|
+
if (value.schemaVersion !== 1)
|
|
64
|
+
fail('unsupported schemaVersion');
|
|
65
|
+
if (value.harness !== harness)
|
|
66
|
+
fail(`harness must be '${harness}'`);
|
|
67
|
+
if (value.channel !== 'stable' && value.channel !== 'nightly')
|
|
68
|
+
fail('channel must be stable or nightly');
|
|
69
|
+
const expectedTag = value.channel === 'stable' ? 'latest' : 'nightly';
|
|
70
|
+
if (value.distTag !== expectedTag)
|
|
71
|
+
fail(`distTag must be '${expectedTag}' for channel '${value.channel}'`);
|
|
72
|
+
if (value.package !== spec.package)
|
|
73
|
+
fail(`package must be '${spec.package}'`);
|
|
74
|
+
if (typeof value.version !== 'string' || !EXACT_SEMVER.test(value.version))
|
|
75
|
+
return fail('version must be an exact semver');
|
|
76
|
+
const version = value.version;
|
|
77
|
+
if (value.channel === 'stable' && version.includes('-'))
|
|
78
|
+
fail('stable channel resolved to a prerelease');
|
|
79
|
+
if (value.channel === 'nightly' && !version.includes('-nightly.'))
|
|
80
|
+
fail('nightly channel did not resolve to a -nightly.N prerelease');
|
|
81
|
+
if (value.registry !== REGISTRY)
|
|
82
|
+
fail(`registry must be '${REGISTRY}'`);
|
|
83
|
+
if (typeof value.resolvedAt !== 'string' || !Number.isFinite(Date.parse(value.resolvedAt)))
|
|
84
|
+
fail('resolvedAt must be an ISO timestamp');
|
|
85
|
+
return value;
|
|
86
|
+
}
|
|
87
|
+
export function readHarnessPluginLock(harness) {
|
|
88
|
+
const path = harnessPluginLockPath(harness);
|
|
89
|
+
if (!existsSync(path))
|
|
90
|
+
return undefined;
|
|
91
|
+
let parsed;
|
|
92
|
+
try {
|
|
93
|
+
parsed = JSON.parse(readFileSync(path, 'utf8'));
|
|
94
|
+
}
|
|
95
|
+
catch (error) {
|
|
96
|
+
throw new Error(`invalid harness plugin lock ${path}: ${error.message}; `
|
|
97
|
+
+ `run \`ours-fleet plugins update ${harness}\``);
|
|
98
|
+
}
|
|
99
|
+
return validateLock(parsed, harness, path);
|
|
100
|
+
}
|
|
101
|
+
function marketplaceDocument(lock) {
|
|
102
|
+
const spec = SPECS[lock.harness];
|
|
103
|
+
const source = lock.harness === 'codex'
|
|
104
|
+
? { source: 'npm', package: lock.package, version: lock.version, registry: lock.registry }
|
|
105
|
+
: { source: 'npm', package: lock.package, version: lock.version };
|
|
106
|
+
if (lock.harness === 'codex') {
|
|
107
|
+
return {
|
|
108
|
+
name: spec.marketplaceName,
|
|
109
|
+
interface: { displayName: `ours.network (${lock.channel}, locked ${lock.version})` },
|
|
110
|
+
plugins: [{
|
|
111
|
+
name: 'ours', source,
|
|
112
|
+
policy: { installation: 'AVAILABLE', authentication: 'ON_INSTALL' },
|
|
113
|
+
category: 'Productivity',
|
|
114
|
+
}],
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
return {
|
|
118
|
+
$schema: 'https://json.schemastore.org/claude-code-marketplace.json',
|
|
119
|
+
name: spec.marketplaceName,
|
|
120
|
+
owner: { name: 'Adapt Framework Solutions Ltd', url: 'https://ours.network' },
|
|
121
|
+
description: `Generated by ours-fleet; @ours.network/claude-code is locked to ${lock.version}.`,
|
|
122
|
+
plugins: [{
|
|
123
|
+
name: 'ours', source,
|
|
124
|
+
description: 'Secure ours.network messaging for Claude Code.',
|
|
125
|
+
}],
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
function writeMarketplace(lock) {
|
|
129
|
+
const path = harnessPluginMarketplacePath(lock.harness);
|
|
130
|
+
replaceFileAtomically(path, `${JSON.stringify(marketplaceDocument(lock), null, 2)}\n`, 0o644);
|
|
131
|
+
return path;
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* Re-materialize the generated marketplace from the persisted exact lock.
|
|
135
|
+
* This is the ONLY ordinary startup/reconciliation path: it performs no exec,
|
|
136
|
+
* no network call, and cannot observe a moving npm dist-tag.
|
|
137
|
+
*/
|
|
138
|
+
export function restoreLockedHarnessMarketplace(harness, expectedChannel) {
|
|
139
|
+
const lock = readHarnessPluginLock(harness);
|
|
140
|
+
if (!lock) {
|
|
141
|
+
if (expectedChannel === 'nightly')
|
|
142
|
+
throw new Error(`harness '${harness}' requests nightly but has no exact lock; `
|
|
143
|
+
+ `run \`ours-fleet plugins install ${harness}\``);
|
|
144
|
+
return undefined;
|
|
145
|
+
}
|
|
146
|
+
if (expectedChannel && lock.channel !== expectedChannel)
|
|
147
|
+
throw new Error(`harness '${harness}' requests ${expectedChannel} but its exact lock is ${lock.channel} `
|
|
148
|
+
+ `(${lock.version}); run \`ours-fleet plugins update ${harness}\``);
|
|
149
|
+
if (lock)
|
|
150
|
+
writeMarketplace(lock);
|
|
151
|
+
return lock;
|
|
152
|
+
}
|
|
153
|
+
async function checked(exec, command, args, action) {
|
|
154
|
+
const result = await exec(command, args);
|
|
155
|
+
if (result.code !== 0)
|
|
156
|
+
throw new Error(`${action} failed (${command} ${args.join(' ')}): ${result.stderr.trim() || result.stdout.trim() || `exit ${result.code}`}`);
|
|
157
|
+
return result;
|
|
158
|
+
}
|
|
159
|
+
function parseJson(output, action) {
|
|
160
|
+
try {
|
|
161
|
+
return JSON.parse(output);
|
|
162
|
+
}
|
|
163
|
+
catch (error) {
|
|
164
|
+
throw new Error(`${action} returned invalid JSON: ${error.message}`);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
async function resolveExactVersion(harness, channel, exec, now) {
|
|
168
|
+
const spec = SPECS[harness];
|
|
169
|
+
const distTag = channel === 'stable' ? 'latest' : 'nightly';
|
|
170
|
+
const result = await checked(exec, 'npm', ['view', `${spec.package}@${distTag}`, 'version', '--json'], `resolve ${spec.package} ${distTag}`);
|
|
171
|
+
const version = parseJson(result.stdout, `npm view ${spec.package}@${distTag}`);
|
|
172
|
+
if (typeof version !== 'string' || !EXACT_SEMVER.test(version))
|
|
173
|
+
throw new Error(`npm view ${spec.package}@${distTag} did not return one exact semver`);
|
|
174
|
+
if (channel === 'stable' && version.includes('-'))
|
|
175
|
+
throw new Error(`refusing stable ${spec.package}: npm latest resolved to prerelease ${version}`);
|
|
176
|
+
if (channel === 'nightly' && !version.includes('-nightly.'))
|
|
177
|
+
throw new Error(`refusing nightly ${spec.package}: npm nightly resolved to ${version}`);
|
|
178
|
+
return {
|
|
179
|
+
schemaVersion: 1,
|
|
180
|
+
harness,
|
|
181
|
+
channel,
|
|
182
|
+
distTag,
|
|
183
|
+
package: spec.package,
|
|
184
|
+
version,
|
|
185
|
+
registry: REGISTRY,
|
|
186
|
+
resolvedAt: now().toISOString(),
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
function marketplaceEntries(harness, output) {
|
|
190
|
+
const parsed = parseJson(output, `${SPECS[harness].executable} plugin marketplace list`);
|
|
191
|
+
if (harness === 'codex') {
|
|
192
|
+
if (!isRecord(parsed) || !Array.isArray(parsed.marketplaces))
|
|
193
|
+
throw new Error('codex plugin marketplace list returned an unexpected shape');
|
|
194
|
+
return parsed.marketplaces.filter(isRecord);
|
|
195
|
+
}
|
|
196
|
+
if (!Array.isArray(parsed))
|
|
197
|
+
throw new Error('claude plugin marketplace list returned an unexpected shape');
|
|
198
|
+
return parsed.filter(isRecord);
|
|
199
|
+
}
|
|
200
|
+
async function ensureMarketplaceRegistered(harness, exec) {
|
|
201
|
+
const spec = SPECS[harness];
|
|
202
|
+
const root = harnessPluginMarketplaceRoot(harness);
|
|
203
|
+
const result = await checked(exec, spec.executable, ['plugin', 'marketplace', 'list', '--json'], `list ${harness} marketplaces`);
|
|
204
|
+
const existing = marketplaceEntries(harness, result.stdout)
|
|
205
|
+
.find(entry => entry.name === spec.marketplaceName);
|
|
206
|
+
if (existing) {
|
|
207
|
+
if (harness === 'codex') {
|
|
208
|
+
const declared = isRecord(existing.marketplaceSource)
|
|
209
|
+
? existing.marketplaceSource.source : existing.root;
|
|
210
|
+
if (typeof declared !== 'string' || resolve(declared) !== resolve(root))
|
|
211
|
+
throw new Error(`marketplace '${spec.marketplaceName}' already exists but does not point to ${root}; `
|
|
212
|
+
+ 'refusing to replace an unrelated marketplace');
|
|
213
|
+
}
|
|
214
|
+
else {
|
|
215
|
+
// Claude copies marketplace contents into its cache, but retains the
|
|
216
|
+
// source kind in list output. Refresh that copy after each lock update.
|
|
217
|
+
if (existing.source !== 'directory')
|
|
218
|
+
throw new Error(`marketplace '${spec.marketplaceName}' already exists but is not a local directory; `
|
|
219
|
+
+ 'refusing to replace an unrelated marketplace');
|
|
220
|
+
await checked(exec, 'claude', ['plugin', 'marketplace', 'update', spec.marketplaceName], `refresh ${harness} locked marketplace`);
|
|
221
|
+
}
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
const args = harness === 'codex'
|
|
225
|
+
? ['plugin', 'marketplace', 'add', root, '--json']
|
|
226
|
+
: ['plugin', 'marketplace', 'add', root, '--scope', 'user'];
|
|
227
|
+
await checked(exec, spec.executable, args, `register ${harness} locked marketplace`);
|
|
228
|
+
}
|
|
229
|
+
async function removeLegacyPluginSelections(harness, exec) {
|
|
230
|
+
const spec = SPECS[harness];
|
|
231
|
+
const result = await checked(exec, spec.executable, ['plugin', 'list', '--json'], `list ${harness} plugins`);
|
|
232
|
+
const parsed = parseJson(result.stdout, `${spec.executable} plugin list`);
|
|
233
|
+
if (harness === 'codex' && (!isRecord(parsed) || !Array.isArray(parsed.installed)))
|
|
234
|
+
throw new Error('codex plugin list returned an unexpected shape');
|
|
235
|
+
if (harness === 'claude-code' && !Array.isArray(parsed))
|
|
236
|
+
throw new Error('claude plugin list returned an unexpected shape');
|
|
237
|
+
const entries = (harness === 'codex'
|
|
238
|
+
? parsed.installed
|
|
239
|
+
: parsed).filter(isRecord);
|
|
240
|
+
const ids = new Set(entries
|
|
241
|
+
.map(entry => harness === 'codex' ? entry.pluginId : entry.id)
|
|
242
|
+
.filter((id) => typeof id === 'string'));
|
|
243
|
+
const legacy = harness === 'codex'
|
|
244
|
+
? ['ours@ours-codex-marketplace', 'ours-fleet@ours-codex-marketplace']
|
|
245
|
+
: ['ours@ours', 'ours@ours.network'];
|
|
246
|
+
for (const selector of legacy.filter(id => ids.has(id))) {
|
|
247
|
+
const args = harness === 'codex'
|
|
248
|
+
? ['plugin', 'remove', selector, '--json']
|
|
249
|
+
: ['plugin', 'uninstall', selector, '--scope', 'user', '--keep-data'];
|
|
250
|
+
await checked(exec, spec.executable, args, `remove legacy moving selection ${selector}`);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
async function installFromLock(lock, exec) {
|
|
254
|
+
const spec = SPECS[lock.harness];
|
|
255
|
+
// The Codex package also owns the ours-codex launcher. Pin that executable to
|
|
256
|
+
// the same exact artifact as the local marketplace; never invoke its broad
|
|
257
|
+
// installer, which may select daemon/SDK packages outside this feature.
|
|
258
|
+
if (lock.harness === 'codex') {
|
|
259
|
+
await checked(exec, 'npm', ['install', '--global', `${lock.package}@${lock.version}`], `install ${lock.package}@${lock.version}`);
|
|
260
|
+
}
|
|
261
|
+
await ensureMarketplaceRegistered(lock.harness, exec);
|
|
262
|
+
const selector = `ours@${spec.marketplaceName}`;
|
|
263
|
+
const args = lock.harness === 'codex'
|
|
264
|
+
? ['plugin', 'add', selector, '--json']
|
|
265
|
+
: ['plugin', 'install', selector, '--scope', 'user'];
|
|
266
|
+
await checked(exec, spec.executable, args, `install ${selector}`);
|
|
267
|
+
// The same plugin under an older Git marketplace is a distinct harness
|
|
268
|
+
// selection and can remain enabled beside the lock. Remove only the known
|
|
269
|
+
// ours selectors, after the exact local selection is installed successfully.
|
|
270
|
+
await removeLegacyPluginSelections(lock.harness, exec);
|
|
271
|
+
}
|
|
272
|
+
/**
|
|
273
|
+
* Explicit install/update transaction.
|
|
274
|
+
*
|
|
275
|
+
* install reuses an existing same-channel lock (deterministic repair/reinstall);
|
|
276
|
+
* update always resolves the requested channel once and advances the lock.
|
|
277
|
+
*/
|
|
278
|
+
export async function installHarnessPlugin(harness, channel, options = {}) {
|
|
279
|
+
const exec = options.exec ?? realExec;
|
|
280
|
+
const root = harnessPluginRoot(harness);
|
|
281
|
+
return withFileLock(`${root}.lock`, async () => {
|
|
282
|
+
const existing = readHarnessPluginLock(harness);
|
|
283
|
+
const mustResolve = options.update === true || !existing || existing.channel !== channel;
|
|
284
|
+
const lock = mustResolve
|
|
285
|
+
? await resolveExactVersion(harness, channel, exec, options.now ?? (() => new Date()))
|
|
286
|
+
: existing;
|
|
287
|
+
if (mustResolve)
|
|
288
|
+
replaceFileAtomically(harnessPluginLockPath(harness), `${JSON.stringify(lock, null, 2)}\n`);
|
|
289
|
+
// Publish the lock first. If installation is interrupted, every retry uses
|
|
290
|
+
// this same exact version; no half-finished transaction can observe a newer
|
|
291
|
+
// dist-tag on its own.
|
|
292
|
+
const marketplacePath = writeMarketplace(lock);
|
|
293
|
+
await installFromLock(lock, exec);
|
|
294
|
+
return {
|
|
295
|
+
lock,
|
|
296
|
+
lockPath: harnessPluginLockPath(harness),
|
|
297
|
+
marketplacePath,
|
|
298
|
+
resolved: mustResolve,
|
|
299
|
+
};
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
export function selectedHarnessPluginIds(values) {
|
|
303
|
+
if (!values.length)
|
|
304
|
+
return [...HARNESS_PLUGIN_IDS];
|
|
305
|
+
const unknown = values.filter(value => !HARNESS_PLUGIN_IDS.includes(value));
|
|
306
|
+
if (unknown.length)
|
|
307
|
+
throw new Error(`unknown harness(es) ${unknown.join(', ')}; allowed: ${HARNESS_PLUGIN_IDS.join(', ')}`);
|
|
308
|
+
return [...new Set(values)];
|
|
309
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -28,3 +28,5 @@ export { doctor } from './doctor.js';
|
|
|
28
28
|
export { runOnce, runTemp } from './runner.js';
|
|
29
29
|
export { Tmux } from './tmux.js';
|
|
30
30
|
export { VERSION } from './version.js';
|
|
31
|
+
export { HARNESS_PLUGIN_IDS, installHarnessPlugin, readHarnessPluginLock, restoreLockedHarnessMarketplace, resolveHarnessPluginConfigs, } from './harness-plugins.js';
|
|
32
|
+
export type { HarnessPluginId, HarnessPluginChannel, HarnessPluginConfig, HarnessPluginConfigs, HarnessPluginLock, HarnessPluginInstallResult, } from './harness-plugins.js';
|
package/dist/index.js
CHANGED
|
@@ -23,3 +23,4 @@ export { doctor } from './doctor.js';
|
|
|
23
23
|
export { runOnce, runTemp } from './runner.js';
|
|
24
24
|
export { Tmux } from './tmux.js';
|
|
25
25
|
export { VERSION } from './version.js';
|
|
26
|
+
export { HARNESS_PLUGIN_IDS, installHarnessPlugin, readHarnessPluginLock, restoreLockedHarnessMarketplace, resolveHarnessPluginConfigs, } from './harness-plugins.js';
|