@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.
Files changed (67) hide show
  1. package/README.md +111 -43
  2. package/dist/application/fleet-query-service.js +12 -0
  3. package/dist/application/role-creation-service.js +3 -1
  4. package/dist/application/types.d.ts +11 -0
  5. package/dist/briefing.js +9 -2
  6. package/dist/build-info.json +7 -6
  7. package/dist/capabilities.d.ts +3 -1
  8. package/dist/capabilities.js +3 -0
  9. package/dist/cli.js +83 -7
  10. package/dist/config.d.ts +11 -3
  11. package/dist/config.js +40 -15
  12. package/dist/creation.d.ts +14 -15
  13. package/dist/creation.js +19 -13
  14. package/dist/docs.d.ts +1 -1
  15. package/dist/docs.js +117 -35
  16. package/dist/doctor.d.ts +1 -5
  17. package/dist/doctor.js +11 -18
  18. package/dist/fleet-proxy.d.ts +5 -0
  19. package/dist/harness/acp-agent.js +11 -6
  20. package/dist/harness/claude-code.js +204 -11
  21. package/dist/harness/codex.d.ts +4 -1
  22. package/dist/harness/codex.js +74 -12
  23. package/dist/harness/types.d.ts +54 -4
  24. package/dist/harness-plugins.d.ts +48 -0
  25. package/dist/harness-plugins.js +309 -0
  26. package/dist/index.d.ts +2 -0
  27. package/dist/index.js +1 -0
  28. package/dist/loops/manager.d.ts +30 -1
  29. package/dist/loops/manager.js +69 -6
  30. package/dist/loops/state.d.ts +18 -0
  31. package/dist/loops/state.js +4 -0
  32. package/dist/model-env.d.ts +71 -0
  33. package/dist/model-env.js +106 -0
  34. package/dist/monitor.js +1 -1
  35. package/dist/ops.js +1 -1
  36. package/dist/owner-channel/attachments.d.ts +2 -25
  37. package/dist/owner-channel/attachments.js +5 -61
  38. package/dist/owner-channel/channel.d.ts +30 -29
  39. package/dist/owner-channel/channel.js +291 -291
  40. package/dist/owner-channel/mcp.d.ts +24 -0
  41. package/dist/owner-channel/mcp.js +145 -0
  42. package/dist/owner-channel/notices.d.ts +7 -0
  43. package/dist/owner-channel/notices.js +9 -0
  44. package/dist/resolved-plan.js +1 -0
  45. package/dist/runner.d.ts +48 -0
  46. package/dist/runner.js +237 -85
  47. package/dist/session/acp.d.ts +104 -0
  48. package/dist/session/acp.js +213 -10
  49. package/dist/session/activity.d.ts +31 -0
  50. package/dist/session/activity.js +48 -0
  51. package/dist/session/conversation-normalizer.d.ts +6 -0
  52. package/dist/session/conversation-normalizer.js +153 -10
  53. package/dist/session/conversation-types.d.ts +23 -4
  54. package/dist/session/types.d.ts +35 -0
  55. package/dist/spawn.js +29 -17
  56. package/dist/supervisor/systemd.js +2 -29
  57. package/dist/watchdog/briefing.js +7 -0
  58. package/dist/web-app/assets/{TerminalView-BAVk1Bot.js → TerminalView-C_G1ID2P.js} +1 -1
  59. package/dist/web-app/assets/{index-C3S-xFRU.js → index-BCBK78hw.js} +5 -5
  60. package/dist/web-app/index.html +1 -1
  61. package/dist/worklog.d.ts +7 -1
  62. package/dist/worklog.js +191 -39
  63. package/package.json +1 -3
  64. package/dist/owner-channel/message-recovery.d.ts +0 -25
  65. package/dist/owner-channel/message-recovery.js +0 -114
  66. package/dist/owner-channel/ours-client.d.ts +0 -148
  67. package/dist/owner-channel/ours-client.js +0 -231
@@ -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 { bundledAcpAgent, resolveBundledAcpAgent } from './acp-agent.js';
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
- /** codex-acp exposes the same sandbox postures as named ACP agent modes. */
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]: sandboxMode(role) ?? 'workspace-write',
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 { argv: [], env: codexAcpEnvironment(role, dirs), command };
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
- : bundledAcpAgent(CODEX_ACP_PACKAGE, 'codex-acp', 'codex-acp');
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 ? { approval, sandbox } : acpModePermissions(mode);
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
- : `codex-acp mode '${mode}' actually uses approval=${actual.approval} `
376
- + `sandbox=${actual.sandbox}, and the bundled ${BUNDLED_CODEX_ACP_VERSION} `
377
- + `app-server override is unavailable; this does not exactly represent `
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
@@ -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: string[];
31
- env: Record<string, string>;
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
- validateOptions(opts: unknown): ValidationError[];
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';