@ours.network/fleet 0.13.3 → 0.14.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.
@@ -1,9 +1,11 @@
1
1
  import { spawn } from 'node:child_process';
2
2
  import { createHash, randomUUID } from 'node:crypto';
3
- import { mkdir, readdir, rm } from 'node:fs/promises';
3
+ import { mkdir, readFile, readdir, rm } from 'node:fs/promises';
4
4
  import { createInterface } from 'node:readline';
5
5
  import { join } from 'node:path';
6
6
  import { DEFAULT_OWNER_ATTACHMENT_MIME, canonicalCid, } from '../config.js';
7
+ import { VERSION } from '../version.js';
8
+ import { dispatchOwnerCommand, fleetCliOps, isOwnerCommandText, } from './commands.js';
7
9
  import { OursMcpClient } from './mcp.js';
8
10
  import { ownerNotices } from './notices.js';
9
11
  import { DuplicateSendError, OwnerAuthorizationState, OwnerChannelState, OwnerConversationState, } from './state.js';
@@ -49,9 +51,11 @@ export class OwnerChannel {
49
51
  activeRequests = new Map();
50
52
  managementTail = Promise.resolve();
51
53
  ready = false;
54
+ fleetOps;
52
55
  constructor(options) {
53
56
  this.options = options;
54
57
  this.client = options.client ?? new OursMcpClient(options.command, options.env, line => options.log(`[${options.role}] owner channel ${line}`));
58
+ this.fleetOps = options.fleet ?? fleetCliOps(options.role, options.configPath);
55
59
  this.state = new OwnerChannelState(join(options.stateDir, '.owner-channel-state.json'));
56
60
  this.authorizations = new OwnerAuthorizationState(join(options.stateDir, '.owner-channel-owners.json'), options.config.owners);
57
61
  this.conversations = new OwnerConversationState(join(options.stateDir, '.owner-channel-conversations.json'));
@@ -692,24 +696,8 @@ export class OwnerChannel {
692
696
  this.logError('owner conversation route update failed', error);
693
697
  }
694
698
  const text = String(message.text ?? '').trim();
695
- if (text.toLowerCase() === '/status') {
696
- const snapshot = this.options.session.snapshot();
697
- await this.send(sender.id, ownerNotices.status(this.options.role, snapshot), wireId);
698
- this.state.remember(wireId);
699
- return true;
700
- }
701
- if (text.toLowerCase() === '/interrupt') {
702
- try {
703
- await this.options.session.interrupt('owner');
704
- }
705
- catch (error) {
706
- this.logError('interrupt failed', error);
707
- await this.send(sender.id, ownerNotices.interruptFailed(this.options.role), wireId);
708
- this.state.remember(wireId);
709
- return true;
710
- }
711
- await this.send(sender.id, ownerNotices.interrupted(this.options.role), wireId);
712
- this.state.remember(wireId);
699
+ if (isOwnerCommandText(text)) {
700
+ await this.handleCommand(sender, text, wireId);
713
701
  return true;
714
702
  }
715
703
  const requestId = this.requestId(wireId);
@@ -760,6 +748,94 @@ export class OwnerChannel {
760
748
  this.completionTasks.add(task);
761
749
  return true;
762
750
  }
751
+ /**
752
+ * Deterministic command path: the message never becomes an agent prompt.
753
+ * Authorization already happened — the managed-agent relay branch and the
754
+ * owner-CID check in handle() both run before dispatch, so only an
755
+ * authenticated owner reaches this: neither ordinary peers nor the managed
756
+ * agent itself can execute /force-restart, /model, or any other command.
757
+ */
758
+ async handleCommand(sender, text, wireId) {
759
+ const ctx = {
760
+ role: this.options.role,
761
+ harness: this.options.harness,
762
+ version: VERSION,
763
+ snapshot: () => this.options.session.snapshot(),
764
+ interrupt: () => this.options.session.interrupt('owner'),
765
+ runHarnessCommand: command => this.runHarnessCommand(sender, command, wireId),
766
+ restart: mode => this.restartSelf(sender, mode, wireId),
767
+ fleetList: () => this.fleetOps.list(),
768
+ recentEvents: limit => this.options.session.eventsSince(0).slice(-limit),
769
+ readWorklogTail: maxChars => this.readWorklogTail(maxChars),
770
+ reply: async (replyText) => { await this.send(sender.id, replyText, wireId); },
771
+ };
772
+ try {
773
+ await dispatchOwnerCommand(text, ctx);
774
+ }
775
+ catch (error) {
776
+ this.logError(`owner command failed (${text.split(/\s+/, 1)[0]})`, error);
777
+ }
778
+ // Harness commands own their wire until the queued turn settles; everything
779
+ // else is complete now and must never replay.
780
+ if (!this.inFlight.has(wireId))
781
+ this.state.remember(wireId);
782
+ }
783
+ /** Queue raw slash text to the harness and report the turn's outcome. */
784
+ async runHarnessCommand(sender, command, wireId) {
785
+ const requestId = this.requestId(wireId);
786
+ const queued = await this.options.session.queuePrompt(command, {
787
+ origin: { kind: 'owner', requestId },
788
+ });
789
+ this.inFlight.add(wireId);
790
+ const receipt = this.send(sender.id, ownerNotices.commandStarted(command), wireId)
791
+ .then(() => undefined)
792
+ .catch(error => this.logError(`command ${command} acceptance notice failed`, error));
793
+ const task = queued.completion.then(async (result) => {
794
+ await receipt;
795
+ const output = result.succeeded ? this.commandOutput(result.output) : undefined;
796
+ await this.send(sender.id, ownerNotices.commandOutcome(command, result.outcome, output), wireId);
797
+ this.state.remember(wireId);
798
+ }).catch(error => this.logError(`command ${command} completion failed`, error))
799
+ .finally(() => {
800
+ this.inFlight.delete(wireId);
801
+ this.completionTasks.delete(task);
802
+ if (!this.stopping)
803
+ void this.drain().catch(error => this.logError('completion drain failed', error));
804
+ });
805
+ this.completionTasks.add(task);
806
+ }
807
+ /**
808
+ * Confirmation and the durable wire record must both land BEFORE the fleet
809
+ * CLI is asked to bounce this very process; neither can happen afterwards.
810
+ */
811
+ async restartSelf(sender, mode, wireId) {
812
+ const command = mode === 'fresh' ? '/force-restart' : '/restart';
813
+ await this.send(sender.id, ownerNotices.restarting(this.options.role, command, mode), wireId);
814
+ this.state.remember(wireId);
815
+ this.options.log(`[${this.options.role}] owner requested ${command}`);
816
+ await this.fleetOps.restart(mode);
817
+ }
818
+ /** Code-point-safe tail of the worklog, or undefined when there is none. */
819
+ async readWorklogTail(maxChars) {
820
+ try {
821
+ const content = (await readFile(join(this.options.stateDir, 'WORKLOG.md'), 'utf8')).trim();
822
+ if (!content)
823
+ return undefined;
824
+ const points = Array.from(content);
825
+ return points.length <= maxChars ? content : `…${points.slice(-maxChars).join('')}`;
826
+ }
827
+ catch {
828
+ return undefined;
829
+ }
830
+ }
831
+ /** Bound harness-command output to a single outbound message. */
832
+ commandOutput(output) {
833
+ const trimmed = output?.trim();
834
+ if (!trimmed)
835
+ return undefined;
836
+ const points = Array.from(trimmed);
837
+ return points.length <= 7_000 ? trimmed : `${points.slice(0, 7_000).join('')}…`;
838
+ }
763
839
  acceptedSender(cid) {
764
840
  return this.isAgentSender(cid) || this.isEffectiveOwner(cid);
765
841
  }
@@ -0,0 +1,79 @@
1
+ import type { SessionEvent, SessionSnapshot } from '../session/types.js';
2
+ /**
3
+ * Fleet-level effects a deterministic owner command may trigger. Production
4
+ * uses the detached CLI (`fleetCliOps`); tests inject fakes so no command can
5
+ * ever bounce a real service from the suite.
6
+ */
7
+ export interface OwnerFleetOps {
8
+ /** `ours-fleet restart` (keep) or `ours-fleet force-restart` (fresh) of this role. */
9
+ restart(mode: 'keep' | 'fresh'): Promise<void>;
10
+ /** `ours-fleet ls` output. */
11
+ list(): Promise<string>;
12
+ }
13
+ /**
14
+ * The narrow capability surface a command executor sees. Everything here is
15
+ * already scoped to the one role whose channel received the message; commands
16
+ * cannot name another agent or another recipient.
17
+ */
18
+ export interface OwnerCommandContext {
19
+ role: string;
20
+ /** Harness id of the role (e.g. 'claude-code', 'codex'); gates forwarding. */
21
+ harness: string;
22
+ version: string;
23
+ snapshot(): SessionSnapshot;
24
+ interrupt(): Promise<void>;
25
+ /**
26
+ * Deliver raw slash text to the agent harness. Only commands the bundled
27
+ * ACP adapter for `harness` verifiably executes locally may be forwarded
28
+ * (see HARNESS_LOCAL_COMMANDS); anything else would reach the model as an
29
+ * ordinary prompt. The channel sends the acceptance and outcome notices
30
+ * itself.
31
+ */
32
+ runHarnessCommand(command: string): Promise<void>;
33
+ restart(mode: 'keep' | 'fresh'): Promise<void>;
34
+ fleetList(): Promise<string>;
35
+ recentEvents(limit: number): SessionEvent[];
36
+ readWorklogTail(maxChars: number): Promise<string | undefined>;
37
+ reply(text: string): Promise<void>;
38
+ }
39
+ export interface OwnerCommand {
40
+ /** Primary name without the leading slash. */
41
+ name: string;
42
+ aliases?: string[];
43
+ /** Shown in help; defaults to `/<name>`. */
44
+ usage?: string;
45
+ /** One-line description shown in help. */
46
+ summary: string;
47
+ execute(ctx: OwnerCommandContext, args: string): Promise<void>;
48
+ }
49
+ /**
50
+ * Commands each harness's bundled ACP adapter verifiably executes locally,
51
+ * pinned by test/acp-adapter-commands.test.ts against the shipped adapter
52
+ * artifacts. claude-agent-acp routes slash commands into the Claude SDK,
53
+ * which runs its builtins (/clear, /compact, /model) without a model turn;
54
+ * codex-acp intercepts only /compact — /clear and /model are not builtins
55
+ * and would fall through into sendPrompt, i.e. reach the model as an
56
+ * ordinary prompt. Unlisted harnesses forward nothing.
57
+ */
58
+ export declare const HARNESS_LOCAL_COMMANDS: Record<string, readonly string[]>;
59
+ /**
60
+ * The single source of truth for the deterministic owner-channel command set:
61
+ * /help renders exactly this table, so adding an entry here is the whole
62
+ * registration step for a new command.
63
+ */
64
+ export declare const ownerCommands: OwnerCommand[];
65
+ /** Trimmed slash-prefixed text is a command attempt and is never forwarded. */
66
+ export declare const isOwnerCommandText: (text: string) => boolean;
67
+ export declare function ownerCommandHelp(error?: string): string;
68
+ /**
69
+ * Execute one authenticated owner command. `text` must already be trimmed,
70
+ * slash-prefixed, and from an authorized owner CID — the channel enforces the
71
+ * authority boundary before dispatch ever sees the message.
72
+ */
73
+ export declare function dispatchOwnerCommand(text: string, ctx: OwnerCommandContext): Promise<void>;
74
+ /**
75
+ * Production fleet effects: the detached ours-fleet CLI. The restart child is
76
+ * detached and unreferenced because a successful restart kills this very
77
+ * process; the reply and the durable wire record must already be on disk.
78
+ */
79
+ export declare function fleetCliOps(role: string, configPath?: string): OwnerFleetOps;
@@ -0,0 +1,183 @@
1
+ import { execFile, spawn } from 'node:child_process';
2
+ import { ownerNotices } from './notices.js';
3
+ /** A malformed invocation; the dispatcher answers it with annotated help. */
4
+ class OwnerCommandUsageError extends Error {
5
+ }
6
+ const MODEL_ID = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
7
+ const REPLY_MAX_CHARS = 3_500;
8
+ const strip = (value, max) => String(value).replace(/[\u0000-\u001f\u007f]/g, ' ').slice(0, max);
9
+ /** Like `strip`, but keeps newlines so multi-line listings stay readable. */
10
+ const stripMultiline = (value, max) => String(value).replace(/[\u0000-\u0009\u000b-\u001f\u007f]/g, ' ').slice(0, max);
11
+ /**
12
+ * Commands each harness's bundled ACP adapter verifiably executes locally,
13
+ * pinned by test/acp-adapter-commands.test.ts against the shipped adapter
14
+ * artifacts. claude-agent-acp routes slash commands into the Claude SDK,
15
+ * which runs its builtins (/clear, /compact, /model) without a model turn;
16
+ * codex-acp intercepts only /compact — /clear and /model are not builtins
17
+ * and would fall through into sendPrompt, i.e. reach the model as an
18
+ * ordinary prompt. Unlisted harnesses forward nothing.
19
+ */
20
+ export const HARNESS_LOCAL_COMMANDS = {
21
+ 'claude-code': ['clear', 'compact', 'model'],
22
+ codex: ['compact'],
23
+ };
24
+ /**
25
+ * Forward raw slash text to the harness only when the bundled adapter for
26
+ * this role's harness verifiably executes it locally; otherwise answer with
27
+ * a truthful refusal so the text can never reach the model as a prompt.
28
+ */
29
+ const forwardHarnessCommand = (ctx, raw) => {
30
+ const name = raw.slice(1).split(/\s+/, 1)[0];
31
+ if (!(HARNESS_LOCAL_COMMANDS[ctx.harness] ?? []).includes(name))
32
+ return ctx.reply(ownerNotices.commandUnsupported(`/${name}`, ctx.harness));
33
+ return ctx.runHarnessCommand(raw);
34
+ };
35
+ /** Keep the LAST characters — tails are more useful than heads for logs. */
36
+ const tail = (value, max) => {
37
+ const points = Array.from(value);
38
+ return points.length <= max ? value : `…${points.slice(-max).join('')}`;
39
+ };
40
+ const noArgs = (usage, run) => async (ctx, args) => {
41
+ if (args)
42
+ throw new OwnerCommandUsageError(`${usage} takes no arguments`);
43
+ await run(ctx);
44
+ };
45
+ /**
46
+ * The single source of truth for the deterministic owner-channel command set:
47
+ * /help renders exactly this table, so adding an entry here is the whole
48
+ * registration step for a new command.
49
+ */
50
+ export const ownerCommands = [
51
+ {
52
+ name: 'help', aliases: ['commands'],
53
+ summary: 'list all deterministic owner-channel commands (alias: /commands)',
54
+ execute: async (ctx) => ctx.reply(ownerCommandHelp()),
55
+ },
56
+ {
57
+ name: 'status', summary: "report the agent's session state",
58
+ execute: noArgs('/status', async (ctx) => ctx.reply(ownerNotices.status(ctx.role, ctx.snapshot()))),
59
+ },
60
+ {
61
+ name: 'interrupt', summary: "cancel the agent's active turn",
62
+ execute: noArgs('/interrupt', async (ctx) => {
63
+ try {
64
+ await ctx.interrupt();
65
+ }
66
+ catch {
67
+ return ctx.reply(ownerNotices.interruptFailed(ctx.role));
68
+ }
69
+ await ctx.reply(ownerNotices.interrupted(ctx.role));
70
+ }),
71
+ },
72
+ {
73
+ name: 'clear', summary: "clear the agent's session context",
74
+ execute: noArgs('/clear', ctx => forwardHarnessCommand(ctx, '/clear')),
75
+ },
76
+ {
77
+ name: 'compact', summary: "compact the agent's session context",
78
+ execute: noArgs('/compact', ctx => forwardHarnessCommand(ctx, '/compact')),
79
+ },
80
+ {
81
+ name: 'model', usage: '/model <model-id>',
82
+ summary: 'switch the model the agent runs on',
83
+ execute: async (ctx, args) => {
84
+ if (!args)
85
+ throw new OwnerCommandUsageError('usage: /model <model-id>');
86
+ if (!MODEL_ID.test(args))
87
+ throw new OwnerCommandUsageError('model id must be alphanumeric with . _ : - only');
88
+ await forwardHarnessCommand(ctx, `/model ${args}`);
89
+ },
90
+ },
91
+ {
92
+ name: 'restart', summary: 'restart the agent, resuming its context',
93
+ execute: noArgs('/restart', ctx => ctx.restart('keep')),
94
+ },
95
+ {
96
+ name: 'force-restart', summary: 'restart the agent FRESH (context wiped)',
97
+ execute: noArgs('/force-restart', ctx => ctx.restart('fresh')),
98
+ },
99
+ {
100
+ name: 'ls', summary: 'list running fleet sessions',
101
+ execute: noArgs('/ls', async (ctx) => ctx.reply(`📊 Fleet sessions:\n${tail(stripMultiline(await ctx.fleetList(), 10_000), REPLY_MAX_CHARS)}`)),
102
+ },
103
+ {
104
+ name: 'peek', summary: 'summarize recent session activity (event shapes only, no content)',
105
+ execute: noArgs('/peek', async (ctx) => {
106
+ const lines = ctx.recentEvents(20).map(event => ['·', event.kind,
107
+ ...(event.title !== undefined ? [strip(event.title, 80)] : []),
108
+ ...(event.status !== undefined ? [`(${strip(event.status, 40)})`] : []),
109
+ ...(event.stopReason !== undefined ? [`(${strip(event.stopReason, 40)})`] : []),
110
+ ].join(' '));
111
+ await ctx.reply(lines.length
112
+ ? tail(`📊 Recent activity for ${ctx.role}:\n${lines.join('\n')}`, REPLY_MAX_CHARS)
113
+ : `📊 No recent session activity recorded for ${ctx.role}.`);
114
+ }),
115
+ },
116
+ {
117
+ name: 'worklog', summary: "tail the agent's worklog",
118
+ execute: noArgs('/worklog', async (ctx) => {
119
+ const worklog = await ctx.readWorklogTail(REPLY_MAX_CHARS);
120
+ await ctx.reply(worklog
121
+ ? `📊 Worklog tail for ${ctx.role}:\n${worklog}`
122
+ : `ℹ️ No worklog found for ${ctx.role}.`);
123
+ }),
124
+ },
125
+ {
126
+ name: 'version', summary: 'report the fleet version',
127
+ execute: noArgs('/version', async (ctx) => ctx.reply(`ℹ️ ours-fleet ${ctx.version}`)),
128
+ },
129
+ ];
130
+ /** Trimmed slash-prefixed text is a command attempt and is never forwarded. */
131
+ export const isOwnerCommandText = (text) => text.trim().startsWith('/');
132
+ export function ownerCommandHelp(error) {
133
+ const table = ownerCommands
134
+ .map(command => `${command.usage ?? `/${command.name}`} — ${command.summary}`)
135
+ .join('\n');
136
+ return `${error ? `⚠️ ${error}\n\n` : ''}🧭 Deterministic owner-channel commands `
137
+ + '(handled by fleet; never sent to the agent as a prompt):\n'
138
+ + `${table}\n`
139
+ + 'Messages without a leading "/" reach the agent unchanged. '
140
+ + 'Unknown or malformed commands return this help.';
141
+ }
142
+ /**
143
+ * Execute one authenticated owner command. `text` must already be trimmed,
144
+ * slash-prefixed, and from an authorized owner CID — the channel enforces the
145
+ * authority boundary before dispatch ever sees the message.
146
+ */
147
+ export async function dispatchOwnerCommand(text, ctx) {
148
+ const trimmed = text.trim();
149
+ const token = trimmed.split(/\s+/, 1)[0];
150
+ const name = token.slice(1).toLowerCase();
151
+ const args = trimmed.slice(token.length).trim();
152
+ const command = ownerCommands.find(entry => entry.name === name || entry.aliases?.includes(name));
153
+ if (!command)
154
+ return ctx.reply(ownerCommandHelp(`unknown command ${strip(token, 60)}`));
155
+ try {
156
+ await command.execute(ctx, args);
157
+ }
158
+ catch (error) {
159
+ if (error instanceof OwnerCommandUsageError)
160
+ return ctx.reply(ownerCommandHelp(error.message));
161
+ // The failure notice carries no internal detail; the channel logs it.
162
+ await ctx.reply(ownerNotices.commandFailed(command.usage ?? `/${command.name}`));
163
+ throw error;
164
+ }
165
+ }
166
+ /**
167
+ * Production fleet effects: the detached ours-fleet CLI. The restart child is
168
+ * detached and unreferenced because a successful restart kills this very
169
+ * process; the reply and the durable wire record must already be on disk.
170
+ */
171
+ export function fleetCliOps(role, configPath) {
172
+ const cli = (args) => [process.argv[1], ...args, ...(configPath ? ['-c', configPath] : [])];
173
+ return {
174
+ restart: mode => new Promise((resolve, reject) => {
175
+ const child = spawn(process.execPath, cli([mode === 'fresh' ? 'force-restart' : 'restart', role]), { detached: true, stdio: 'ignore' });
176
+ child.once('error', reject);
177
+ child.once('spawn', () => { child.unref(); resolve(); });
178
+ }),
179
+ list: () => new Promise((resolve, reject) => {
180
+ execFile(process.execPath, [process.argv[1], 'ls'], { timeout: 15_000, maxBuffer: 256 * 1024 }, (error, stdout) => error ? reject(error) : resolve(String(stdout).trim()));
181
+ }),
182
+ };
183
+ }
@@ -9,6 +9,11 @@ export declare const ownerNotices: {
9
9
  status: (role: string, snapshot: SessionSnapshot) => string;
10
10
  interrupted: (role: string) => string;
11
11
  interruptFailed: (role: string) => string;
12
+ commandStarted: (command: string) => string;
13
+ commandOutcome: (command: string, outcome: TurnOutcome, output?: string) => string;
14
+ commandFailed: (command: string) => string;
15
+ commandUnsupported: (command: string, harness: string) => string;
16
+ restarting: (role: string, command: string, mode: "keep" | "fresh") => string;
12
17
  attachmentRejected: (reason: string) => string;
13
18
  attachmentFailed: () => string;
14
19
  deliveryFailed: (role: string) => string;
@@ -22,6 +22,23 @@ export const ownerNotices = {
22
22
  status: (role, snapshot) => `📊 ${role} status: ${snapshot.readiness}; session is ${snapshot.alive ? 'online' : 'offline'}.`,
23
23
  interrupted: (role) => `🛑 Interrupt sent to ${role}'s active turn.`,
24
24
  interruptFailed: (role) => `⚠️ Could not interrupt ${role}'s active turn.`,
25
+ commandStarted: (command) => `⏳ Running ${command} — the result will follow in this channel.`,
26
+ commandOutcome: (command, outcome, output) => {
27
+ switch (outcome) {
28
+ case 'completed': return `✅ ${command} completed.${output ? `\n${output}` : ''}`;
29
+ case 'cancelled': return `🛑 ${command} was cancelled before completion.`;
30
+ case 'refused': return `⚠️ ${command} was declined by the agent harness.`;
31
+ case 'failed': return `⚠️ ${command} failed before completion.`;
32
+ case 'inconclusive': return `⚠️ ${command} ended without a confirmed completion.`;
33
+ }
34
+ },
35
+ commandFailed: (command) => `⚠️ ${command} could not be executed.`,
36
+ commandUnsupported: (command, harness) => `⚠️ ${command} is not supported on the '${harness}' harness: its bundled ACP `
37
+ + 'adapter does not execute it locally, so forwarding it would deliver the '
38
+ + 'text to the model as an ordinary prompt. Nothing was forwarded.',
39
+ restarting: (role, command, mode) => `ℹ️ ${command} accepted — restarting ${role} ${mode === 'fresh'
40
+ ? 'FRESH (context wiped)' : '(context resumes)'}. `
41
+ + 'The channel goes quiet during the restart and resumes when the agent is back.',
25
42
  attachmentRejected: (reason) => `⚠️ Attachment rejected: ${reason}.`,
26
43
  attachmentFailed: () => '⚠️ Could not securely retrieve or admit this attachment request.',
27
44
  deliveryFailed: (role) => `⚠️ Could not deliver this request to ${role}.`,
package/dist/runner.js CHANGED
@@ -520,11 +520,13 @@ export async function runOnce(name, opts = {}, partialDeps = {}) {
520
520
  if (role.owner_channel) {
521
521
  ownerChannel = deps.createOwnerChannel({
522
522
  role: name,
523
+ harness: role.harness,
523
524
  config: role.owner_channel,
524
525
  session: arbiter,
525
526
  stateDir: dir,
526
527
  env: role.env,
527
528
  log: deps.log,
529
+ ...(configPath ? { configPath } : {}),
528
530
  });
529
531
  try {
530
532
  await ownerChannel.start();
@@ -0,0 +1,19 @@
1
+ export type WebAccessMode = 'pairing' | 'password' | 'none';
2
+ export interface WebAccessConfig {
3
+ version: 1;
4
+ mode: WebAccessMode;
5
+ password?: {
6
+ salt: string;
7
+ hash: string;
8
+ };
9
+ }
10
+ export declare function passwordAccess(password: string): WebAccessConfig;
11
+ export declare function verifyPassword(config: WebAccessConfig, supplied: string): boolean;
12
+ export declare class WebAccessStore {
13
+ private readonly dir;
14
+ readonly path: string;
15
+ constructor(dir?: string);
16
+ read(): WebAccessConfig;
17
+ write(config: WebAccessConfig): void;
18
+ }
19
+ export declare function validatePublicOrigin(value: string): URL;
@@ -0,0 +1,70 @@
1
+ import { randomBytes, scryptSync, timingSafeEqual } from 'node:crypto';
2
+ import { chmodSync, lstatSync, mkdirSync, readFileSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { replaceFileAtomically } from '../atomic-file.js';
5
+ import { FleetError } from '../application/errors.js';
6
+ import { stateRoot } from '../paths.js';
7
+ const DEFAULT = { version: 1, mode: 'pairing' };
8
+ const b64 = /^[A-Za-z0-9_-]{20,128}$/;
9
+ export function passwordAccess(password) {
10
+ if (password.length < 12 || Buffer.byteLength(password) > 1024)
11
+ throw new FleetError('invalid_request', 'control-panel password must be 12–1024 bytes');
12
+ const salt = randomBytes(16).toString('base64url');
13
+ return { version: 1, mode: 'password', password: {
14
+ salt, hash: scryptSync(password, salt, 32).toString('base64url'),
15
+ } };
16
+ }
17
+ export function verifyPassword(config, supplied) {
18
+ if (config.mode !== 'password' || !config.password || Buffer.byteLength(supplied) > 1024)
19
+ return false;
20
+ const actual = scryptSync(supplied, config.password.salt, 32);
21
+ const expected = Buffer.from(config.password.hash, 'base64url');
22
+ return actual.length === expected.length && timingSafeEqual(actual, expected);
23
+ }
24
+ export class WebAccessStore {
25
+ dir;
26
+ path;
27
+ constructor(dir = join(stateRoot(), 'web')) {
28
+ this.dir = dir;
29
+ this.path = join(dir, 'access.json');
30
+ }
31
+ read() {
32
+ try {
33
+ const stat = lstatSync(this.path);
34
+ if (!stat.isFile() || stat.isSymbolicLink() || stat.size > 16 * 1024)
35
+ throw new FleetError('forbidden', 'web access configuration is not a safe regular file');
36
+ chmodSync(this.path, 0o600);
37
+ const value = JSON.parse(readFileSync(this.path, 'utf8'));
38
+ if (value.version !== 1 || !['pairing', 'password', 'none'].includes(value.mode ?? ''))
39
+ throw new FleetError('forbidden', 'web access configuration is invalid');
40
+ if (value.mode === 'password' && (!value.password || !b64.test(value.password.salt)
41
+ || !b64.test(value.password.hash)))
42
+ throw new FleetError('forbidden', 'web password configuration is invalid');
43
+ return value;
44
+ }
45
+ catch (error) {
46
+ if (error.code === 'ENOENT')
47
+ return DEFAULT;
48
+ throw error;
49
+ }
50
+ }
51
+ write(config) {
52
+ mkdirSync(this.dir, { recursive: true, mode: 0o700 });
53
+ chmodSync(this.dir, 0o700);
54
+ replaceFileAtomically(this.path, JSON.stringify(config, null, 2) + '\n', 0o600);
55
+ chmodSync(this.path, 0o600);
56
+ }
57
+ }
58
+ export function validatePublicOrigin(value) {
59
+ let origin;
60
+ try {
61
+ origin = new URL(value);
62
+ }
63
+ catch {
64
+ throw new FleetError('invalid_request', 'public origin must be an absolute http(s) URL');
65
+ }
66
+ if (!['http:', 'https:'].includes(origin.protocol) || origin.username || origin.password
67
+ || origin.pathname !== '/' || origin.search || origin.hash)
68
+ throw new FleetError('invalid_request', 'public origin must contain only scheme, host, and optional port');
69
+ return origin;
70
+ }
@@ -1,6 +1,7 @@
1
1
  import type { FastifyRequest } from 'fastify';
2
2
  import type { WebSocket } from 'ws';
3
3
  import { TrustedDeviceStore, type TrustedDeviceIssue } from './device-store.js';
4
+ import { type WebAccessConfig } from './access.js';
4
5
  export interface BrowserSession {
5
6
  id: string;
6
7
  csrf: string;
@@ -24,6 +25,7 @@ export declare class WebAuth {
24
25
  private _host;
25
26
  private readonly now;
26
27
  private readonly devices;
28
+ private readonly access;
27
29
  private _bootstrapSecret;
28
30
  private bootstrapExpiresAt;
29
31
  private bootstrapUsed;
@@ -32,15 +34,24 @@ export declare class WebAuth {
32
34
  private readonly tickets;
33
35
  private readonly rates;
34
36
  private readonly sockets;
35
- constructor(_origin: string, _host: string, now?: () => number, devices?: TrustedDeviceStore);
37
+ constructor(_origin: string, _host: string, now?: () => number, devices?: TrustedDeviceStore, access?: WebAccessConfig);
36
38
  get bootstrapSecret(): string;
37
39
  get origin(): string;
38
40
  get host(): string;
39
- setBoundary(origin: string, host: string): void;
41
+ get mode(): WebAccessConfig['mode'];
42
+ get secureCookies(): boolean;
43
+ private allowedOrigins;
44
+ private allowedHosts;
45
+ setBoundary(origin: string, host: string, aliases?: {
46
+ origins?: string[];
47
+ hosts?: string[];
48
+ }): void;
40
49
  /** Mint a replacement for an operator-triggered reauthentication ceremony. */
41
50
  mintBootstrap(): string;
42
51
  validateBoundary(request: FastifyRequest, requireOrigin: boolean): void;
43
52
  exchange(request: FastifyRequest): AuthResult;
53
+ login(request: FastifyRequest, password: string): AuthResult;
54
+ anonymous(request: FastifyRequest): BrowserSession;
44
55
  resume(request: FastifyRequest): AuthResult;
45
56
  authenticate(request: FastifyRequest, mutation?: boolean): BrowserSession;
46
57
  logout(request: FastifyRequest): void;