@ours.network/fleet 1.2.0-nightly.36 → 1.2.0-nightly.38
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/dist/build-info.json +4 -4
- package/dist/harness/claude-code.js +9 -1
- package/dist/harness/claude-runtime.d.ts +12 -0
- package/dist/harness/claude-runtime.js +43 -0
- package/dist/init-wizard.d.ts +2 -0
- package/dist/init-wizard.js +16 -9
- package/dist/preset-migration.js +10 -0
- package/dist/session/acp.d.ts +4 -1
- package/dist/session/acp.js +7 -6
- package/dist/session/codex-app-server.js +1 -2
- package/dist/web/server.js +6 -2
- package/dist/workspace-enrollment.d.ts +11 -3
- package/dist/workspace-enrollment.js +157 -24
- package/package.json +1 -1
- package/presets/fleet/roles/Coordinator.yaml +12 -17
- package/presets/manifest.json +1 -1
package/dist/build-info.json
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
{
|
|
2
|
-
"version": "1.2.0-nightly.
|
|
3
|
-
"buildId": "
|
|
4
|
-
"commit": "
|
|
2
|
+
"version": "1.2.0-nightly.38",
|
|
3
|
+
"buildId": "2eaafbd42c9d",
|
|
4
|
+
"commit": "86c7af960f9b2cfdbf4dcddee25bc7303e78780c",
|
|
5
5
|
"dirty": true,
|
|
6
|
-
"builtAt": "2026-10-
|
|
6
|
+
"builtAt": "2026-10-02T21:39:35.791Z",
|
|
7
7
|
"capabilities": [
|
|
8
8
|
"workspace.enroll.preserve-profile-v1",
|
|
9
9
|
"cowork.http-management-v1",
|
|
@@ -6,7 +6,8 @@ import { realExec } from '../exec.js';
|
|
|
6
6
|
import { registerAdapter } from './registry.js';
|
|
7
7
|
import { replaceFileAtomically, withFileLock } from '../atomic-file.js';
|
|
8
8
|
import { harnessRuntimeDir } from '../isolation/policy.js';
|
|
9
|
-
import { bundledAcpAgent } from './acp-agent.js';
|
|
9
|
+
import { bundledAcpAgent, resolveBundledAcpAgent } from './acp-agent.js';
|
|
10
|
+
import { hostClaude } from './claude-runtime.js';
|
|
10
11
|
import { ClaudeCodeAgentSessionAdapter } from './claude-code-session.js';
|
|
11
12
|
const OPTION_KEYS = [
|
|
12
13
|
'plugins', 'mem_palace', 'mem_palace_midsession_autosave', 'permission_mode', 'effort',
|
|
@@ -283,6 +284,13 @@ export function makeClaudeCodeAdapter(exec = realExec, transport) {
|
|
|
283
284
|
};
|
|
284
285
|
if (!memPalace)
|
|
285
286
|
env.MEMPALACE_DISABLED = 'true';
|
|
287
|
+
// The bundled adapter runs the Claude Code shipped with its SDK unless told otherwise. A host
|
|
288
|
+
// Claude Code at least as new as that copy is the one the person updates, so sessions run it.
|
|
289
|
+
if (!customAcpCommand(role)) {
|
|
290
|
+
const host = hostClaude(resolveBundledAcpAgent('@agentclientprotocol/claude-agent-acp', 'claude-agent-acp', 'claude-agent-acp').manifestPath, { ...process.env, ...role.env }, dirs.runCwd);
|
|
291
|
+
if (host)
|
|
292
|
+
env.CLAUDE_CODE_EXECUTABLE = host.path;
|
|
293
|
+
}
|
|
286
294
|
// Per-role harness runtime home. Created before sandbox entry so the
|
|
287
295
|
// bind has something to mount; harmless for un-isolated roles.
|
|
288
296
|
// Only a role that declares `isolation:` gets a sandbox, and only a
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/** The Claude Code version shipped inside the adapter's own SDK dependency. */
|
|
2
|
+
export declare function bundledClaudeVersion(adapterManifest: string): string | undefined;
|
|
3
|
+
/**
|
|
4
|
+
* The host's own Claude Code, when ACP sessions should run it instead of the copy bundled with the adapter.
|
|
5
|
+
* The person signs in to, updates and checks the host Claude Code, and a newer one knows models the bundled
|
|
6
|
+
* one does not. It is used only when it is at least as new as the bundled copy; an explicit
|
|
7
|
+
* CLAUDE_CODE_EXECUTABLE, in the role or the service environment, always decides instead.
|
|
8
|
+
*/
|
|
9
|
+
export declare function hostClaude(adapterManifest: string | undefined, env: NodeJS.ProcessEnv, cwd?: string, version?: (path: string, env: NodeJS.ProcessEnv) => string | undefined): {
|
|
10
|
+
path: string;
|
|
11
|
+
version: string;
|
|
12
|
+
} | undefined;
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { createRequire } from 'node:module';
|
|
3
|
+
import { dirname, join } from 'node:path';
|
|
4
|
+
import { spawnSync } from 'node:child_process';
|
|
5
|
+
import { codexVersionAtLeast, executableOnPath } from './codex-runtime.js';
|
|
6
|
+
const versionOf = (text) => text.match(/\b(\d+\.\d+\.\d+)\b/)?.[1];
|
|
7
|
+
function installedClaudeVersion(path, env) {
|
|
8
|
+
const result = spawnSync(path, ['--version'], { env, encoding: 'utf8', timeout: 5_000, stdio: ['ignore', 'pipe', 'ignore'] });
|
|
9
|
+
return result.status === 0 ? versionOf(result.stdout ?? '') : undefined;
|
|
10
|
+
}
|
|
11
|
+
/** The Claude Code version shipped inside the adapter's own SDK dependency. */
|
|
12
|
+
export function bundledClaudeVersion(adapterManifest) {
|
|
13
|
+
try {
|
|
14
|
+
const sdk = dirname(createRequire(adapterManifest).resolve('@anthropic-ai/claude-agent-sdk'));
|
|
15
|
+
const version = JSON.parse(readFileSync(join(sdk, 'package.json'), 'utf8')).claudeCodeVersion;
|
|
16
|
+
return typeof version === 'string' ? version : undefined;
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
return undefined;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* The host's own Claude Code, when ACP sessions should run it instead of the copy bundled with the adapter.
|
|
24
|
+
* The person signs in to, updates and checks the host Claude Code, and a newer one knows models the bundled
|
|
25
|
+
* one does not. It is used only when it is at least as new as the bundled copy; an explicit
|
|
26
|
+
* CLAUDE_CODE_EXECUTABLE, in the role or the service environment, always decides instead.
|
|
27
|
+
*/
|
|
28
|
+
export function hostClaude(adapterManifest, env, cwd = process.cwd(), version = installedClaudeVersion) {
|
|
29
|
+
if (env.CLAUDE_CODE_EXECUTABLE !== undefined || !adapterManifest)
|
|
30
|
+
return undefined;
|
|
31
|
+
const bundled = bundledClaudeVersion(adapterManifest);
|
|
32
|
+
if (!bundled)
|
|
33
|
+
return undefined;
|
|
34
|
+
let path;
|
|
35
|
+
try {
|
|
36
|
+
path = executableOnPath('claude', env, cwd);
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
return undefined;
|
|
40
|
+
}
|
|
41
|
+
const found = version(path, env);
|
|
42
|
+
return found && codexVersionAtLeast(found, bundled) ? { path, version: found } : undefined;
|
|
43
|
+
}
|
package/dist/init-wizard.d.ts
CHANGED
|
@@ -53,6 +53,8 @@ export declare function validateCatalog(value: BrainCatalog, path?: string): Bra
|
|
|
53
53
|
export declare function readInitSettings(path: string): InitAnswers;
|
|
54
54
|
export declare function askInitQuestions(prompter: InitPrompter, configuration: string): Promise<InitAnswers | undefined>;
|
|
55
55
|
export declare function formatSetupSummary(answers: InitAnswers, configuration: string): string;
|
|
56
|
+
/** The packaged Brain preset for a catalog model at one effort; the same naming the presets are generated with. */
|
|
57
|
+
export declare function brainPresetId(model: Pick<CatalogModel, 'harness' | 'model'>, effort: string): string;
|
|
56
58
|
/** Build the exact, deterministic default experience without writing to disk. */
|
|
57
59
|
export declare function generateSetup(answers: InitAnswers): GeneratedSetup;
|
|
58
60
|
/** Run the mutation boundary only after the complete questionnaire is accepted. */
|
package/dist/init-wizard.js
CHANGED
|
@@ -173,6 +173,7 @@ export function formatSetupSummary(answers, configuration) {
|
|
|
173
173
|
' Team: LocalCoordinator + Developer + Critic',
|
|
174
174
|
' Gated engineering: Engineer (gate pipeline) + independent Critic',
|
|
175
175
|
' FleetCoordinator: coordination model',
|
|
176
|
+
' Brains: the packaged presets; each prepared agent references the preset of its chosen model',
|
|
176
177
|
'',
|
|
177
178
|
'After the final Yes, Fleet performs host service setup before publishing the complete configuration.',
|
|
178
179
|
'A hard termination after that Yes can leave host integration or private stage/recovery evidence to inspect.',
|
|
@@ -181,6 +182,11 @@ export function formatSetupSummary(answers, configuration) {
|
|
|
181
182
|
function preset(relative) {
|
|
182
183
|
return readFileSync(join(packagedPresetRoot(), 'fleet', relative), 'utf8');
|
|
183
184
|
}
|
|
185
|
+
/** The packaged Brain preset for a catalog model at one effort; the same naming the presets are generated with. */
|
|
186
|
+
export function brainPresetId(model, effort) {
|
|
187
|
+
return `${model.harness === 'claude-code' ? 'claude' : 'codex'}-${model.model
|
|
188
|
+
.replace(/^claude-/, '').replace(/\./g, '-').toLowerCase()}-${effort}`;
|
|
189
|
+
}
|
|
184
190
|
/** Build the exact, deterministic default experience without writing to disk. */
|
|
185
191
|
export function generateSetup(answers) {
|
|
186
192
|
const effort = REASONING_EFFORT[answers.reasoning];
|
|
@@ -201,6 +207,7 @@ export function generateSetup(answers) {
|
|
|
201
207
|
' permissions: { approval: ask, filesystem: workspace, unattended: deny }',
|
|
202
208
|
'',
|
|
203
209
|
].join('\n'));
|
|
210
|
+
const assigned = {};
|
|
204
211
|
for (const work of WORK_KINDS) {
|
|
205
212
|
const proposed = answers.models[work];
|
|
206
213
|
if (!proposed || !answers.subscriptions.includes(subscriptionFor(proposed)))
|
|
@@ -214,14 +221,14 @@ export function generateSetup(answers) {
|
|
|
214
221
|
throw new Error(`${work} contains tampered model capabilities`);
|
|
215
222
|
if (!model.efforts.includes(effort))
|
|
216
223
|
throw new Error(`${MODEL_LABELS[model.model]} does not support ${answers.reasoning} reasoning`);
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
`
|
|
221
|
-
|
|
222
|
-
'',
|
|
223
|
-
].join('\n'));
|
|
224
|
+
// The chosen model is assigned by reference to its packaged Brain preset; setup defines no Brain of its own.
|
|
225
|
+
const id = brainPresetId(model, effort);
|
|
226
|
+
if (!existsSync(join(packagedPresetRoot(), 'fleet', 'brains', `${id}.yaml`)))
|
|
227
|
+
throw new Error(`no packaged Brain preset for ${MODEL_LABELS[model.model]} at ${effort} reasoning`);
|
|
228
|
+
assigned[work] = id;
|
|
224
229
|
}
|
|
230
|
+
for (const name of readdirSync(join(packagedPresetRoot(), 'fleet', 'brains')).filter(name => name.endsWith('.yaml')).sort())
|
|
231
|
+
files.set(`brains/${name}`, preset(`brains/${name}`));
|
|
225
232
|
if (answers.assignmentStrategy === 'one-model') {
|
|
226
233
|
const tuples = new Set(WORK_KINDS.map(work => {
|
|
227
234
|
const model = answers.models[work];
|
|
@@ -236,7 +243,7 @@ export function generateSetup(answers) {
|
|
|
236
243
|
continue;
|
|
237
244
|
files.set(`agent_templates/${role}.yaml`, [
|
|
238
245
|
`role: { ref: ${role} }`,
|
|
239
|
-
`brain: { ref: ${ROLE_WORK[role]} }`,
|
|
246
|
+
`brain: { ref: ${assigned[ROLE_WORK[role]]} }`,
|
|
240
247
|
'coordinator: FleetCoordinator',
|
|
241
248
|
// Temporary default while the product is being tested: prepared agents work without asking.
|
|
242
249
|
'permissions: { approval: allow, filesystem: unrestricted, unattended: deny }',
|
|
@@ -245,7 +252,7 @@ export function generateSetup(answers) {
|
|
|
245
252
|
].join('\n'));
|
|
246
253
|
}
|
|
247
254
|
files.set('agents/FleetCoordinator.yaml', preset('agents/FleetCoordinator.yaml')
|
|
248
|
-
.replace('brain: { ref: claude-default }',
|
|
255
|
+
.replace('brain: { ref: claude-default }', `brain: { ref: ${assigned.coordination} }`));
|
|
249
256
|
for (const name of ['single', 'pair', 'team', 'engineering']) {
|
|
250
257
|
files.set(`room_templates/${name}.yaml`, preset(`room_templates/${name}.yaml`));
|
|
251
258
|
files.set(`room_layouts/${name}.yaml`, preset(`room_layouts/${name}.yaml`));
|
package/dist/preset-migration.js
CHANGED
|
@@ -67,6 +67,14 @@ const V5_GENERATED_ROLE_DEFAULT_FINGERPRINTS = Object.freeze({
|
|
|
67
67
|
'agent_templates/Developer.yaml': 'fdd953aab72d042e62780dd273598745aae59521b39edf4876d7676bb7189c83',
|
|
68
68
|
'agent_templates/Critic.yaml': '3e98a28f17876061cfadf984351aa59fdbe19629e514eff52c74546545c64a91',
|
|
69
69
|
});
|
|
70
|
+
// The packaged Coordinator role of revisions 8 and 9 addressed a named owner channel that a
|
|
71
|
+
// fresh installation does not have. Untouched copies adopt the current role; edited ones stay.
|
|
72
|
+
const OWNER_CHANNEL_COORDINATOR_FINGERPRINTS = Object.freeze({
|
|
73
|
+
'roles/Coordinator.yaml': [
|
|
74
|
+
'8c8ad28d9bb9ddf9dba8c5c9c7859f25edd8fa10ebf833e2340124a6e49583ec',
|
|
75
|
+
'f0d8b169ad0a18051b57e5280b93cf01e05faa5c12f4e12bdf89973edf2ef58a',
|
|
76
|
+
],
|
|
77
|
+
});
|
|
70
78
|
const CURRENT_ROLE_DEFAULTS = new Set([
|
|
71
79
|
'roles/Coordinator.yaml', 'roles/LocalCoordinator.yaml', 'roles/Developer.yaml', 'roles/Critic.yaml',
|
|
72
80
|
'roles/Engineer.yaml',
|
|
@@ -196,6 +204,7 @@ export function migratePackagedRoleDefaults(configuration, options = {}, hooks =
|
|
|
196
204
|
...Object.keys(V3_ROLE_DEFAULT_FINGERPRINTS),
|
|
197
205
|
...Object.keys(V4_ROLE_DEFAULT_FINGERPRINTS),
|
|
198
206
|
...Object.keys(V5_ROLE_DEFAULT_FINGERPRINTS),
|
|
207
|
+
...Object.keys(OWNER_CHANNEL_COORDINATOR_FINGERPRINTS),
|
|
199
208
|
]);
|
|
200
209
|
for (const relative of known) {
|
|
201
210
|
const path = join(root, relative);
|
|
@@ -209,6 +218,7 @@ export function migratePackagedRoleDefaults(configuration, options = {}, hooks =
|
|
|
209
218
|
V4_GENERATED_ROLE_DEFAULT_FINGERPRINTS[relative],
|
|
210
219
|
V5_ROLE_DEFAULT_FINGERPRINTS[relative],
|
|
211
220
|
V5_GENERATED_ROLE_DEFAULT_FINGERPRINTS[relative],
|
|
221
|
+
...(OWNER_CHANNEL_COORDINATOR_FINGERPRINTS[relative] ?? []),
|
|
212
222
|
].some(expected => expected !== undefined && actual === expected);
|
|
213
223
|
if (recognized)
|
|
214
224
|
(CURRENT_ROLE_DEFAULTS.has(relative) ? replacements : removals).push(path);
|
package/dist/session/acp.d.ts
CHANGED
|
@@ -339,7 +339,10 @@ export declare class AcpSession implements AgentSession {
|
|
|
339
339
|
* signal. Never infer commentary from text, message order, or unknown meta.
|
|
340
340
|
*/
|
|
341
341
|
private codexMessagePhase;
|
|
342
|
-
/**
|
|
342
|
+
/**
|
|
343
|
+
* Normalize every ACP update losslessly into the durable ledger. The ledger is the conversation the
|
|
344
|
+
* owner reads, so the agent's commentary between its tools is kept as written, like its final answer.
|
|
345
|
+
*/
|
|
343
346
|
private recordConversationUpdate;
|
|
344
347
|
taskNoticeBinding(): TaskNoticeBinding | undefined;
|
|
345
348
|
recordTaskCreated(binding: TaskNoticeBinding, notice: TaskCreatedNotice): void;
|
package/dist/session/acp.js
CHANGED
|
@@ -38,7 +38,6 @@ const TERMINAL_TOOL_STATUSES = new Set(['completed', 'failed']);
|
|
|
38
38
|
/** Consumed only by Fleet's authenticated bundled-Codex app-server proxy. */
|
|
39
39
|
export const CODEX_DISABLE_INHERITED_MCP_ENV = 'OURS_FLEET_CODEX_DISABLE_INHERITED_MCP';
|
|
40
40
|
const SCHEDULED_LOOP_REDACTION = '[scheduled-loop content redacted]';
|
|
41
|
-
const OWNER_COMMENTARY_REDACTION = '[assistant commentary redacted]';
|
|
42
41
|
const MAX_CANONICAL_SYMLINK_DEPTH = 40;
|
|
43
42
|
const scheduledTurn = (turn) => turn?.origin?.kind === 'scheduled-loop';
|
|
44
43
|
/**
|
|
@@ -1711,7 +1710,7 @@ export class AcpSession {
|
|
|
1711
1710
|
const scheduled = this.activeTurn?.origin?.kind === 'scheduled-loop';
|
|
1712
1711
|
const messagePhase = update.sessionUpdate === 'agent_message_chunk'
|
|
1713
1712
|
? this.codexMessagePhase(update) : undefined;
|
|
1714
|
-
this.recordConversationUpdate(update, scheduled
|
|
1713
|
+
this.recordConversationUpdate(update, scheduled);
|
|
1715
1714
|
if (update.sessionUpdate === 'config_option_update')
|
|
1716
1715
|
this.captureRuntimeMetadata(update.configOptions);
|
|
1717
1716
|
if (update.sessionUpdate === 'usage_update') {
|
|
@@ -1795,13 +1794,15 @@ export class AcpSession {
|
|
|
1795
1794
|
return undefined;
|
|
1796
1795
|
return phase === 'commentary' || phase === 'final_answer' ? phase : 'ambiguous';
|
|
1797
1796
|
}
|
|
1798
|
-
/**
|
|
1799
|
-
|
|
1797
|
+
/**
|
|
1798
|
+
* Normalize every ACP update losslessly into the durable ledger. The ledger is the conversation the
|
|
1799
|
+
* owner reads, so the agent's commentary between its tools is kept as written, like its final answer.
|
|
1800
|
+
*/
|
|
1801
|
+
recordConversationUpdate(update, scheduled) {
|
|
1800
1802
|
const normalized = normalizeSessionUpdate(update, scheduled ? {
|
|
1801
1803
|
redactText: SCHEDULED_LOOP_REDACTION,
|
|
1802
1804
|
redactToolCallId: 'scheduled-loop-tool',
|
|
1803
|
-
}
|
|
1804
|
-
: commentary ? { redactText: OWNER_COMMENTARY_REDACTION } : {});
|
|
1805
|
+
} : {});
|
|
1805
1806
|
this.conversation.appendSafe({
|
|
1806
1807
|
kind: normalized.kind,
|
|
1807
1808
|
sessionGeneration: this.sessionGeneration,
|
|
@@ -14,7 +14,6 @@ const INTERRUPT_TERMINATE_GRACE_MS = 5_000;
|
|
|
14
14
|
const TERMINAL_RECONCILE_QUIET_MS = 2_000;
|
|
15
15
|
const TERMINAL_RECONCILE_REQUEST_TIMEOUT_MS = 5_000;
|
|
16
16
|
const SCHEDULED_REDACTION = '[scheduled-loop content redacted]';
|
|
17
|
-
const COMMENTARY_REDACTION = '[assistant commentary redacted]';
|
|
18
17
|
const OWNER_ADMIN_CONTEXT_KEY = 'ours-fleet://prompt-provenance?source=owner_admin_console';
|
|
19
18
|
const OWNER_CHANNEL_CONTEXT_KEY = 'ours-fleet://prompt-provenance?source=owner_channel';
|
|
20
19
|
const isObject = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
@@ -719,7 +718,7 @@ export class CodexAppServerSession {
|
|
|
719
718
|
promptId: this.activeTurn?.promptId, turnId: this.activeTurn?.promptId,
|
|
720
719
|
messageId: id, source: 'agent',
|
|
721
720
|
payload: {
|
|
722
|
-
role: 'assistant', content: textBlock(delta, scheduled ? SCHEDULED_REDACTION :
|
|
721
|
+
role: 'assistant', content: textBlock(delta, scheduled ? SCHEDULED_REDACTION : undefined),
|
|
723
722
|
},
|
|
724
723
|
});
|
|
725
724
|
}
|
package/dist/web/server.js
CHANGED
|
@@ -202,9 +202,13 @@ export async function buildWebServer(services, boundary, options = {}) {
|
|
|
202
202
|
throw new FleetError('backend_failure', `Fleet setup did not complete: ${error.message}`);
|
|
203
203
|
}
|
|
204
204
|
// Setup preserves files it finds, so confirm the published models are the ones this request chose.
|
|
205
|
-
|
|
205
|
+
// The prepared agents reference packaged Brain presets, so resolve what each of them now points at:
|
|
206
|
+
// a preserved file of a packaged name may hold another model, session or reasoning than its name says.
|
|
207
|
+
const published = configuration.read(true).model, brains = published.brains ?? {};
|
|
208
|
+
const brainOf = (holder) => brains[String(holder?.brain?.ref ?? '')];
|
|
209
|
+
const assigned = { coordination: brainOf(published.agents.FleetCoordinator), development: brainOf(published.agent_templates?.Developer), review: brainOf(published.agent_templates?.Critic) };
|
|
206
210
|
for (const work of ONBOARDING_WORK)
|
|
207
|
-
if (
|
|
211
|
+
if (assigned[work]?.harness !== models[work].harness || assigned[work]?.model !== models[work].model || assigned[work]?.session !== models[work].session || assigned[work]?.effort !== 'medium')
|
|
208
212
|
throw new FleetError('conflict', 'Fleet was set up with different models; review its configuration');
|
|
209
213
|
return { configured: true };
|
|
210
214
|
});
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type AttachOursClientOptions, type OursClient } from '@ours.network/sdk/client';
|
|
1
2
|
export interface WorkspacePayload {
|
|
2
3
|
version: 1;
|
|
3
4
|
appOrigin: string;
|
|
@@ -16,18 +17,24 @@ export interface WorkspacePayload {
|
|
|
16
17
|
};
|
|
17
18
|
}
|
|
18
19
|
export declare function readWorkspacePayload(file: string): WorkspacePayload;
|
|
20
|
+
type DaemonClient = Pick<OursClient, 'listIdentities' | 'chooseIdentity' | 'addContact' | 'listContacts' | 'sendCommand' | 'removeContact' | 'releaseLease' | 'close'>;
|
|
21
|
+
/** How tunnel setup reaches this host's daemon; replaced only by tests. */
|
|
22
|
+
export type AttachDaemonClient = (options: AttachOursClientOptions) => Promise<DaemonClient>;
|
|
19
23
|
/**
|
|
20
24
|
* Check the Fleet configuration tunnel setup will use, before the one-time command is used.
|
|
21
25
|
* Reads only; an existing Owner is compared with this host's own Messenger identity.
|
|
22
26
|
*/
|
|
23
|
-
export declare function checkWorkspaceConfiguration(configuration?: string): Promise<void>;
|
|
27
|
+
export declare function checkWorkspaceConfiguration(configuration?: string, attach?: AttachDaemonClient): Promise<void>;
|
|
24
28
|
export declare function enrollWorkspace(payload: WorkspacePayload, configuration?: string, options?: {
|
|
25
29
|
migrateAppOrigin?: boolean;
|
|
26
30
|
preserveProfile?: boolean;
|
|
31
|
+
attach?: AttachDaemonClient;
|
|
32
|
+
contactWaitMs?: number;
|
|
33
|
+
afterInvitation?: () => void;
|
|
27
34
|
}): Promise<{
|
|
28
35
|
origin: string;
|
|
29
36
|
hostWorkspaceId: string;
|
|
30
|
-
rootCid:
|
|
37
|
+
rootCid: string;
|
|
31
38
|
}>;
|
|
32
39
|
/** Only the existing signed-root receipt authorizes the scoped tunnel target. */
|
|
33
40
|
/**
|
|
@@ -35,7 +42,7 @@ export declare function enrollWorkspace(payload: WorkspacePayload, configuration
|
|
|
35
42
|
* that binding, remove exactly that contact from this host's Messenger, so it does not stay in the
|
|
36
43
|
* person's chats. The server identity is the one recorded with the binding; nothing else is touched.
|
|
37
44
|
*/
|
|
38
|
-
export declare function removeEnrollmentContact(): Promise<boolean>;
|
|
45
|
+
export declare function removeEnrollmentContact(attach?: AttachDaemonClient): Promise<boolean>;
|
|
39
46
|
export declare function configureWorkspacePort(payload: Pick<WorkspacePayload, 'appOrigin' | 'challenge'>, hostWorkspaceId: string, rootCid: string, port: number): Promise<void>;
|
|
40
47
|
/**
|
|
41
48
|
* Point the tunnel at this host's port. The account answers only for a confirmed signed binding, and
|
|
@@ -43,3 +50,4 @@ export declare function configureWorkspacePort(payload: Pick<WorkspacePayload, '
|
|
|
43
50
|
* so the setup can be finished later. A removal that fails is reported and does not undo the setup.
|
|
44
51
|
*/
|
|
45
52
|
export declare function confirmWorkspaceTarget(payload: Pick<WorkspacePayload, 'appOrigin' | 'challenge'>, hostWorkspaceId: string, rootCid: string, port: number, cleanupFailed?: (error: unknown) => void): Promise<void>;
|
|
53
|
+
export {};
|
|
@@ -5,7 +5,9 @@ import { readClientProfile } from './client-profile.js';
|
|
|
5
5
|
import { stateRoot, defaultConfigPath, workspaceOwnerInvite } from './paths.js';
|
|
6
6
|
import { replaceFileAtomically } from './atomic-file.js';
|
|
7
7
|
import { loadConfig, splitRootFor } from './config.js';
|
|
8
|
-
import { parse, stringify } from 'yaml';
|
|
8
|
+
import { parse, parseDocument, stringify } from 'yaml';
|
|
9
|
+
import { randomUUID } from 'node:crypto';
|
|
10
|
+
import { attachOursClient } from '@ours.network/sdk/client';
|
|
9
11
|
import { preflightInitPaths } from './init-wizard.js';
|
|
10
12
|
import { WebAccessStore } from './web/access.js';
|
|
11
13
|
import { WorkspaceDeviceStore } from './web/workspace-devices.js';
|
|
@@ -71,14 +73,105 @@ function hostMessenger() {
|
|
|
71
73
|
const secret = readFileSync(profile.credentialPath, 'utf8').trim();
|
|
72
74
|
return async (path, value) => { const response = await fetch(new URL('api/' + path, base), { method: value === undefined ? 'GET' : 'POST', headers: { 'X-Ours-Api-Token': secret, ...(value === undefined ? {} : { 'Content-Type': 'application/json', 'Origin': base.origin, 'X-Ours-Messenger-CSRF': '1' }) }, ...(value === undefined ? {} : { body: JSON.stringify(value) }), signal: AbortSignal.timeout(value === undefined ? 15000 : 60000), redirect: 'error' }); if (!response.ok) {
|
|
73
75
|
await response.body?.cancel();
|
|
74
|
-
throw Error('Workspace Messenger enrollment failed (HTTP ' + response.status + '); request a fresh setup payload before retrying');
|
|
76
|
+
throw Object.assign(Error('Workspace Messenger enrollment failed (HTTP ' + response.status + '); request a fresh setup payload before retrying'), { status: response.status });
|
|
75
77
|
} return response.json(); };
|
|
76
78
|
}
|
|
79
|
+
const daemon = (attach, purpose) => { const profile = readClientProfile(); return attach({ endpoint: profile.endpoint, expectedInstanceId: profile.expectedInstanceId, credentialPath: profile.credentialPath, sessionMode: 'external', leaseToken: `ours-fleet-workspace-${purpose}-${process.pid}-${randomUUID()}`, env: {} }); };
|
|
80
|
+
const CID = /^[a-f0-9]{64}$/i, same = (a, b) => a.toLowerCase() === b.toLowerCase();
|
|
81
|
+
/**
|
|
82
|
+
* Who this host's Messenger is. Messenger either runs as the Human root itself, or as the person's own
|
|
83
|
+
* identity and then names the root the daemon describes for it. That is accepted only when this host's
|
|
84
|
+
* daemon, asked by Fleet itself, lists exactly one Human root, that root is the named one, and the
|
|
85
|
+
* Messenger identity is listed as a permanent identity under it.
|
|
86
|
+
*/
|
|
87
|
+
async function messengerIdentity(request, attach) {
|
|
88
|
+
const bound = await request('workspace/enrollment-identity');
|
|
89
|
+
if (bound.rootCid === undefined)
|
|
90
|
+
return { cid: bound.cid, preserveProfile: bound.preserveProfile === true };
|
|
91
|
+
if (typeof bound.cid !== 'string' || !CID.test(bound.cid) || typeof bound.rootCid !== 'string' || !CID.test(bound.rootCid) || same(bound.cid, bound.rootCid))
|
|
92
|
+
throw Error('Bound Messenger identity unavailable');
|
|
93
|
+
const root = await humanRoot(attach, bound.rootCid, bound.cid);
|
|
94
|
+
return { cid: bound.cid, preserveProfile: bound.preserveProfile === true, root };
|
|
95
|
+
}
|
|
96
|
+
/** This host's only Human root, as its daemon lists it; optionally with a permanent identity that must be listed under it. */
|
|
97
|
+
async function humanRoot(attach, rootCid, under) {
|
|
98
|
+
const client = await daemon(attach, 'identities');
|
|
99
|
+
const rows = await client.listIdentities().finally(() => client.close());
|
|
100
|
+
const roots = rows.filter(row => 'kind' in row && row.kind === 'root'), root = roots[0], row = under === undefined ? undefined : rows.find(row => 'cid' in row && same(row.cid, under));
|
|
101
|
+
if (roots.length !== 1 || !('kind' in root) || !same(root.cid, rootCid) || (under !== undefined && (!row || !('kind' in row) || row.kind !== 'role' || row.temp !== null)))
|
|
102
|
+
throw Error("This host's Messenger identity is not a permanent identity under this host's Human root. Tunnel setup changed nothing.");
|
|
103
|
+
return { name: root.name, cid: root.cid };
|
|
104
|
+
}
|
|
105
|
+
/** Run one operation as the Human root and always give the root back, whatever happens. */
|
|
106
|
+
async function asHumanRoot(attach, root, purpose, use) {
|
|
107
|
+
const client = await daemon(attach, purpose);
|
|
108
|
+
let done = false;
|
|
109
|
+
try {
|
|
110
|
+
const bound = await client.chooseIdentity({ name: root.name, force: false }).catch((error) => {
|
|
111
|
+
throw Error(`This host's Human root cannot be used right now (${error.code ?? error.message}). Close the session that holds it, then run setup again. Nothing was changed.`);
|
|
112
|
+
});
|
|
113
|
+
if (!same(bound.cid, root.cid))
|
|
114
|
+
throw Error("This host's Human root changed while setup was running. Nothing was changed.");
|
|
115
|
+
const result = await use(client);
|
|
116
|
+
// The operation is not reported as done while the root may still be held. The release is attempted exactly once.
|
|
117
|
+
done = true;
|
|
118
|
+
const released = await client.releaseLease();
|
|
119
|
+
if (released.failed > 0)
|
|
120
|
+
throw Error("This host's Human root was not given back completely. Run setup again.");
|
|
121
|
+
return result;
|
|
122
|
+
}
|
|
123
|
+
finally {
|
|
124
|
+
// After a failure the root is released once as well; what failed first is what is reported.
|
|
125
|
+
if (!done)
|
|
126
|
+
await client.releaseLease().catch(() => { });
|
|
127
|
+
await client.close().catch(() => { });
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
/** The signed workspace binding, sent by the Human root itself: the same command Messenger sends when it runs as the root. */
|
|
131
|
+
async function proveWithHumanRoot(attach, root, payload, hostWorkspaceId, contactWaitMs) {
|
|
132
|
+
const serverCid = payload.serverCid.toUpperCase();
|
|
133
|
+
await asHumanRoot(attach, root, 'proof', async (client) => {
|
|
134
|
+
const peer = await client.addContact({ invite: payload.invitation });
|
|
135
|
+
if (peer.cid.toUpperCase() !== serverCid)
|
|
136
|
+
throw Error('Enrollment server identity mismatch');
|
|
137
|
+
const deadline = Date.now() + contactWaitMs;
|
|
138
|
+
while (!(await client.listContacts()).contacts.some(contact => contact.container_id.toUpperCase() === serverCid)) {
|
|
139
|
+
if (Date.now() >= deadline)
|
|
140
|
+
throw Error('Enrollment contact is not ready; obtain a fresh setup payload before retrying');
|
|
141
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
142
|
+
}
|
|
143
|
+
const outcome = await client.sendCommand({ contact: serverCid, command: 'bind-workspace', arguments: { type: 'ours.app.bind-workspace.v1', accountId: payload.challenge.accountId, workspaceId: payload.challenge.workspaceId, nonce: payload.challenge.nonce, hostWorkspaceId } });
|
|
144
|
+
if (!('sent' in outcome) || !outcome.sent)
|
|
145
|
+
throw Error('Workspace proof was not sent');
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* True when the Owner entry is the one tunnel setup manages and it still names the Human root of an
|
|
150
|
+
* installation whose Messenger now runs as the person's own identity under that root. Only then is the
|
|
151
|
+
* entry moved to that identity; an Owner entry anyone else wrote is never touched.
|
|
152
|
+
*/
|
|
153
|
+
function managedRootOwner(configuration, rooms, root) {
|
|
154
|
+
if (!same(rooms.owner.expected_cid, root.cid) || rooms.owner.public_invite_file !== workspaceOwnerInvite() || rooms.defaults?.attach_owner === false)
|
|
155
|
+
return false;
|
|
156
|
+
if (!existsSync(join(stateRoot(), 'workspace', 'binding.json')))
|
|
157
|
+
return false;
|
|
158
|
+
let written;
|
|
159
|
+
try {
|
|
160
|
+
written = parseDocument(readFileSync(configuration, 'utf8')).toJS()?.rooms;
|
|
161
|
+
}
|
|
162
|
+
catch {
|
|
163
|
+
return false;
|
|
164
|
+
}
|
|
165
|
+
const entry = written?.owner, defaults = written?.defaults, keys = (value) => Object.keys(value).sort().join();
|
|
166
|
+
// Exactly the Owner entry and defaults tunnel setup writes; anything added or changed beside them is someone's own configuration.
|
|
167
|
+
return !!written && keys(written) === 'defaults,owner' && !!entry && keys(entry) === 'expected_cid,provider,public_invite_file,role' && entry.provider === 'messenger-server' && entry.role === 'Owner' && typeof entry.expected_cid === 'string' && same(entry.expected_cid, root.cid)
|
|
168
|
+
&& !!defaults && keys(defaults) === 'attach_owner,close_when_task_done' && defaults.attach_owner === true && defaults.close_when_task_done === true;
|
|
169
|
+
}
|
|
77
170
|
/**
|
|
78
171
|
* Check the Fleet configuration tunnel setup will use, before the one-time command is used.
|
|
79
172
|
* Reads only; an existing Owner is compared with this host's own Messenger identity.
|
|
80
173
|
*/
|
|
81
|
-
export async function checkWorkspaceConfiguration(configuration = defaultConfigPath()) {
|
|
174
|
+
export async function checkWorkspaceConfiguration(configuration = defaultConfigPath(), attach = attachOursClient) {
|
|
82
175
|
const paths = preflightInitPaths(configuration);
|
|
83
176
|
if (!paths.manifestExisted) {
|
|
84
177
|
if (paths.rootExisted)
|
|
@@ -92,19 +185,20 @@ export async function checkWorkspaceConfiguration(configuration = defaultConfigP
|
|
|
92
185
|
throw Error(`${paths.configPath}: rooms.defaults.attach_owner is disabled; enable it before tunnel setup.`);
|
|
93
186
|
if (!loaded.rooms.owner.public_invite_file)
|
|
94
187
|
throw Error(`${paths.configPath}: rooms.owner must use public_invite_file before tunnel setup.`);
|
|
95
|
-
let cid;
|
|
188
|
+
let cid, root;
|
|
96
189
|
try {
|
|
97
|
-
cid =
|
|
190
|
+
({ cid, root } = await messengerIdentity(hostMessenger(), attach));
|
|
98
191
|
}
|
|
99
192
|
catch {
|
|
100
193
|
cid = undefined;
|
|
101
194
|
}
|
|
102
195
|
if (typeof cid !== 'string' || !/^[a-f0-9]{64}$/i.test(cid))
|
|
103
196
|
throw Error(`${paths.configPath} names a room Owner, and this host's Messenger identity cannot be read to compare with it. Start Messenger on this host first.`);
|
|
104
|
-
if (loaded.rooms.owner.expected_cid.toLowerCase() !== cid.toLowerCase() && !recordsLostEnrollment(paths.configPath))
|
|
197
|
+
if (loaded.rooms.owner.expected_cid.toLowerCase() !== cid.toLowerCase() && !recordsLostEnrollment(paths.configPath) && !(root && managedRootOwner(paths.configPath, loaded.rooms, root)))
|
|
105
198
|
throw Error(`${paths.configPath}: rooms.owner.expected_cid names a different Owner than this host's Messenger identity. Tunnel setup does not replace an existing Owner.`);
|
|
106
199
|
}
|
|
107
200
|
export async function enrollWorkspace(payload, configuration = defaultConfigPath(), options = {}) {
|
|
201
|
+
const attach = options.attach ?? attachOursClient;
|
|
108
202
|
const request = hostMessenger();
|
|
109
203
|
if (new WebAccessStore().read().mode === 'none')
|
|
110
204
|
throw Error('Workspace enrollment requires protected web access; enable pairing or password before setup');
|
|
@@ -131,12 +225,15 @@ export async function enrollWorkspace(payload, configuration = defaultConfigPath
|
|
|
131
225
|
throw Error('Existing rooms.defaults.attach_owner is disabled; enable automatic Human Owner admission before workspace setup');
|
|
132
226
|
if (loaded.rooms && !loaded.rooms.owner.public_invite_file)
|
|
133
227
|
throw Error('Existing room owner configuration must be reviewed before workspace enrollment');
|
|
134
|
-
const identity = await request
|
|
228
|
+
const identity = await messengerIdentity(request, attach);
|
|
135
229
|
if (!/^[a-f0-9]{64}$/i.test(identity.cid))
|
|
136
230
|
throw Error('Bound Messenger identity unavailable');
|
|
231
|
+
// An installation that ran Messenger as the Human root and now runs it as the person's own identity keeps its managed Owner entry, moved to that identity.
|
|
232
|
+
const moved = Boolean(loaded.rooms) && !lost && !!identity.root && !same(loaded.rooms.owner.expected_cid, identity.cid) && managedRootOwner(resolve(configuration), loaded.rooms, identity.root)
|
|
233
|
+
&& JSON.parse(readFileSync(previousFile, 'utf8')).serverCid?.toUpperCase() === payload.serverCid.toUpperCase();
|
|
137
234
|
if (options.preserveProfile && identity.preserveProfile !== true)
|
|
138
235
|
throw Error('Existing-host setup requires Messenger profile-preservation support; update the host Messenger before retrying');
|
|
139
|
-
if (loaded.rooms && !lost && loaded.rooms.owner.expected_cid.toLowerCase() !== identity.cid.toLowerCase())
|
|
236
|
+
if (loaded.rooms && !lost && !moved && loaded.rooms.owner.expected_cid.toLowerCase() !== identity.cid.toLowerCase())
|
|
140
237
|
throw Error('Retained room owner CID conflicts with the Human root');
|
|
141
238
|
const inviteFile = loaded.rooms?.owner.public_invite_file || join(dir, 'owner.invite');
|
|
142
239
|
if (existsSync(inviteFile)) {
|
|
@@ -148,14 +245,30 @@ export async function enrollWorkspace(payload, configuration = defaultConfigPath
|
|
|
148
245
|
const hostWorkspaceId = devices.workspaceId;
|
|
149
246
|
devices.close();
|
|
150
247
|
const { name, surname, ...proofPayload } = payload;
|
|
151
|
-
|
|
152
|
-
if (
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
248
|
+
let result, ownerCid;
|
|
249
|
+
if (identity.root) {
|
|
250
|
+
// The Human root signs the binding; the person's own Messenger identity is the room Owner and issues the Owner invitation.
|
|
251
|
+
await proveWithHumanRoot(attach, identity.root, payload, hostWorkspaceId, options.contactWaitMs ?? 10000);
|
|
252
|
+
const invite = await request('invites', { mode: 'public' });
|
|
253
|
+
if (typeof invite.blob !== 'string' || !invite.blob)
|
|
254
|
+
throw Error('Malformed workspace enrollment response');
|
|
255
|
+
const after = await request('workspace/enrollment-identity');
|
|
256
|
+
if (typeof after.cid !== 'string' || !same(after.cid, identity.cid) || typeof after.rootCid !== 'string' || !same(after.rootCid, identity.root.cid))
|
|
257
|
+
throw Error("This host's Messenger identity changed while setup was running; local configuration is unchanged");
|
|
258
|
+
result = { submitted: true, rootCid: identity.root.cid, ownerInvite: invite.blob };
|
|
259
|
+
ownerCid = identity.cid;
|
|
260
|
+
}
|
|
261
|
+
else {
|
|
262
|
+
result = await request('workspace/enroll', options.preserveProfile ? { ...proofPayload, hostWorkspaceId, preserveProfile: true } : { ...payload, hostWorkspaceId });
|
|
263
|
+
if (!result.submitted || !/^[a-f0-9]{64}$/i.test(result.rootCid) || typeof result.ownerInvite !== 'string')
|
|
264
|
+
throw Error('Malformed workspace enrollment response');
|
|
265
|
+
// Whatever the configuration said before, what is recorded from here on is this host's own root.
|
|
266
|
+
if (result.rootCid.toLowerCase() !== identity.cid.toLowerCase())
|
|
267
|
+
throw Error('Workspace enrollment answered for a different root than this host identity; local configuration is unchanged');
|
|
268
|
+
if (loaded.rooms && !lost && loaded.rooms.owner.expected_cid.toLowerCase() !== result.rootCid.toLowerCase())
|
|
269
|
+
throw Error('Retained room owner CID conflicts with the Human root');
|
|
270
|
+
ownerCid = result.rootCid;
|
|
271
|
+
}
|
|
159
272
|
if (migrating) {
|
|
160
273
|
const deadline = Date.now() + 15000;
|
|
161
274
|
while (true) {
|
|
@@ -173,16 +286,24 @@ export async function enrollWorkspace(payload, configuration = defaultConfigPath
|
|
|
173
286
|
}
|
|
174
287
|
}
|
|
175
288
|
replaceFileAtomically(inviteFile, result.ownerInvite + '\n', 0o600);
|
|
289
|
+
options.afterInvitation?.();
|
|
176
290
|
const split = splitRootFor(configuration);
|
|
177
291
|
mkdirSync(split, { recursive: true, mode: 0o700 });
|
|
178
292
|
if (!loaded.rooms)
|
|
179
|
-
replaceFileAtomically(configuration, readFileSync(configuration, 'utf8') + '\n' + stringify(generatedRooms(
|
|
180
|
-
else if (lost && loaded.rooms.owner.expected_cid.toLowerCase() !==
|
|
181
|
-
replaceFileAtomically(configuration, `api_version: ${GENERATED_VERSION}\n\n` + stringify(generatedRooms(
|
|
293
|
+
replaceFileAtomically(configuration, readFileSync(configuration, 'utf8') + '\n' + stringify(generatedRooms(ownerCid, inviteFile)), 0o600);
|
|
294
|
+
else if (lost && loaded.rooms.owner.expected_cid.toLowerCase() !== ownerCid.toLowerCase())
|
|
295
|
+
replaceFileAtomically(configuration, `api_version: ${GENERATED_VERSION}\n\n` + stringify(generatedRooms(ownerCid, inviteFile)), 0o600);
|
|
296
|
+
else if (moved) {
|
|
297
|
+
// The invitation above and this entry are two files. A setup interrupted between them leaves the entry naming the root,
|
|
298
|
+
// which is this same case again: running setup once more writes both.
|
|
299
|
+
const document = parseDocument(readFileSync(configuration, 'utf8'));
|
|
300
|
+
document.setIn(['rooms', 'owner', 'expected_cid'], ownerCid);
|
|
301
|
+
replaceFileAtomically(configuration, document.toString(), 0o600);
|
|
302
|
+
}
|
|
182
303
|
replaceFileAtomically(join(dir, 'connector'), payload.connectorToken + '\n', 0o600);
|
|
183
304
|
const origin = `https://${payload.hostname}`;
|
|
184
305
|
replaceFileAtomically(join(dir, 'tunnel.json'), JSON.stringify({ origin, tokenFile: join(dir, 'connector') }) + '\n', 0o600);
|
|
185
|
-
replaceFileAtomically(previousFile, JSON.stringify({ workspaceId: payload.challenge.workspaceId, hostWorkspaceId, appOrigin, serverCid: payload.serverCid.toUpperCase() }) + '\n', 0o600);
|
|
306
|
+
replaceFileAtomically(previousFile, JSON.stringify({ workspaceId: payload.challenge.workspaceId, hostWorkspaceId, appOrigin, serverCid: payload.serverCid.toUpperCase(), ...(identity.root ? { proofRootCid: identity.root.cid } : {}) }) + '\n', 0o600);
|
|
186
307
|
loadConfig(configuration);
|
|
187
308
|
return { origin, hostWorkspaceId, rootCid: result.rootCid };
|
|
188
309
|
}
|
|
@@ -192,18 +313,30 @@ export async function enrollWorkspace(payload, configuration = defaultConfigPath
|
|
|
192
313
|
* that binding, remove exactly that contact from this host's Messenger, so it does not stay in the
|
|
193
314
|
* person's chats. The server identity is the one recorded with the binding; nothing else is touched.
|
|
194
315
|
*/
|
|
195
|
-
export async function removeEnrollmentContact() {
|
|
196
|
-
let serverCid;
|
|
316
|
+
export async function removeEnrollmentContact(attach = attachOursClient) {
|
|
317
|
+
let serverCid, proofRootCid;
|
|
197
318
|
try {
|
|
198
|
-
serverCid = JSON.parse(readFileSync(join(stateRoot(), 'workspace', 'binding.json'), 'utf8'))
|
|
319
|
+
({ serverCid, proofRootCid } = JSON.parse(readFileSync(join(stateRoot(), 'workspace', 'binding.json'), 'utf8')));
|
|
199
320
|
}
|
|
200
321
|
catch {
|
|
201
322
|
return false;
|
|
202
323
|
}
|
|
203
324
|
if (typeof serverCid !== 'string' || !/^[A-F0-9]{64}$/i.test(serverCid))
|
|
204
325
|
return false;
|
|
326
|
+
const contact = serverCid.toUpperCase();
|
|
327
|
+
// The contact belongs to whoever sent the binding. When that was the Human root itself, the binding records it.
|
|
328
|
+
if (typeof proofRootCid === 'string' && CID.test(proofRootCid)) {
|
|
329
|
+
try {
|
|
330
|
+
const root = await humanRoot(attach, proofRootCid);
|
|
331
|
+
await asHumanRoot(attach, root, 'cleanup', client => client.removeContact({ contact }));
|
|
332
|
+
}
|
|
333
|
+
catch {
|
|
334
|
+
throw Error('the Human root did not remove it');
|
|
335
|
+
}
|
|
336
|
+
return true;
|
|
337
|
+
}
|
|
205
338
|
try {
|
|
206
|
-
await hostMessenger()('contacts/remove', { contact
|
|
339
|
+
await hostMessenger()('contacts/remove', { contact });
|
|
207
340
|
}
|
|
208
341
|
catch {
|
|
209
342
|
throw Error('Messenger did not remove it');
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ours.network/fleet",
|
|
3
|
-
"version": "1.2.0-nightly.
|
|
3
|
+
"version": "1.2.0-nightly.38",
|
|
4
4
|
"description": "Harness-agnostic fleet of persistent, identity-bound AI agents. Declarative fleet.yaml, managed native/ACP sessions, supervision, and ours.network messaging.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "FSL-1.1-Apache-2.0",
|
|
@@ -2,16 +2,12 @@ mission: Keep fleet development moving by managing the task backlog, rooms, and
|
|
|
2
2
|
persona: |
|
|
3
3
|
You are the fleet Coordinator. Own progress and workflow, not execution quality or results.
|
|
4
4
|
|
|
5
|
-
Each time your session starts, before anything else,
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
how
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
Immediately before each meaningful action, proactively use ours `send_message` to contact
|
|
12
|
-
`Coordinator Owner Channel` with a short, plain one-sentence statement of what you are about to do.
|
|
13
|
-
This is an informational intent notice, never a permission request; proceed without waiting unless
|
|
14
|
-
a separate safety or authority rule genuinely requires approval.
|
|
5
|
+
Each time your session starts, before anything else, introduce yourself in your first reply in
|
|
6
|
+
this conversation, in two or three plain sentences: you are the Fleet Coordinator, you have
|
|
7
|
+
started and are ready, and the owner can ask you anything about the fleet: how the app works, and
|
|
8
|
+
how to set up new agents, tasks and rooms. Do this once per start; it is not a permission request
|
|
9
|
+
and needs no reply. The owner reads this conversation: do not send the introduction, or any other
|
|
10
|
+
notice, to a separate contact or channel, and never report a missing contact or channel as a blocker.
|
|
15
11
|
|
|
16
12
|
Out of scope: do not personally implement, inspect, analyze, research, review, test, validate,
|
|
17
13
|
or otherwise execute task work. Delegate all execution to temporary agents in managed rooms.
|
|
@@ -43,13 +39,12 @@ persona: |
|
|
|
43
39
|
For every new task:
|
|
44
40
|
1. Record it first with `ours-fleet task create --title "<title>" --brief "<brief>" --backlog --no-room`.
|
|
45
41
|
2. Ask the owner which room template to use: `single`, `pair`, or `team`.
|
|
46
|
-
3. Resolve the exact launch plan without creating the room. Immediately before room creation,
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
4. Only after confirmed delivery, start it with
|
|
42
|
+
3. Resolve the exact launch plan without creating the room. Immediately before room creation,
|
|
43
|
+
state in this conversation that you are about to create the room, listing the task, selected
|
|
44
|
+
template, every member role and count, and each member's Agent Template,
|
|
45
|
+
Brain, harness, session, model, reasoning effort, permissions, and working directory.
|
|
46
|
+
This is a notice, not a permission request; do not wait for a reply.
|
|
47
|
+
4. Then start it with
|
|
53
48
|
`ours-fleet task start <task-id> --template <template>`.
|
|
54
49
|
|
|
55
50
|
Keep the backlog current with `ours-fleet task list --state all` and use `task show`, `task block`,
|