@ours.network/fleet 1.2.0-nightly.44 → 1.2.0-nightly.46
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +75 -0
- package/dist/application/role-removal-service.js +2 -0
- package/dist/briefing.d.ts +2 -0
- package/dist/briefing.js +8 -1
- package/dist/build-info.json +5 -4
- package/dist/capabilities.d.ts +3 -1
- package/dist/capabilities.js +3 -0
- package/dist/cli.js +180 -12
- package/dist/config.d.ts +10 -0
- package/dist/config.js +13 -2
- package/dist/docs.d.ts +1 -1
- package/dist/docs.js +82 -0
- package/dist/doctor.js +27 -0
- package/dist/fleet-command-audit.d.ts +6 -0
- package/dist/fleet-command-audit.js +15 -3
- package/dist/managed-cli.d.ts +253 -0
- package/dist/managed-cli.js +922 -0
- package/dist/notifications/fleet-producer.d.ts +1 -0
- package/dist/notifications/fleet-producer.js +82 -3
- package/dist/notifications/outbox.d.ts +4 -0
- package/dist/notifications/outbox.js +9 -0
- package/dist/notifications/target-cleanup.d.ts +8 -0
- package/dist/notifications/target-cleanup.js +41 -0
- package/dist/ops.js +5 -1
- package/dist/resolved-plan.js +1 -0
- package/dist/runner.js +9 -1
- package/dist/spawn.js +1 -1
- package/dist/tunnel-setup-input.d.ts +11 -0
- package/dist/tunnel-setup-input.js +42 -0
- package/dist/web/workspace-devices.d.ts +2 -0
- package/dist/web/workspace-devices.js +13 -0
- package/dist/workspace-enrollment.d.ts +30 -2
- package/dist/workspace-enrollment.js +205 -12
- package/dist/workspace-replacement.d.ts +66 -0
- package/dist/workspace-replacement.js +220 -0
- package/docs/validation/managed-cli-permissions.md +222 -3
- package/package.json +1 -1
|
@@ -10,6 +10,7 @@ export declare class FleetNotificationProducer {
|
|
|
10
10
|
private readonly queues;
|
|
11
11
|
private readonly timer;
|
|
12
12
|
private readonly existing;
|
|
13
|
+
private cleanup?;
|
|
13
14
|
constructor(roots: string[], stateDir: string, config: ProducerConfig, warn?: (line: string) => void);
|
|
14
15
|
private names;
|
|
15
16
|
poll(): void;
|
|
@@ -1,7 +1,42 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { roomsDir } from '../rooms-tasks/room-state.js';
|
|
2
|
+
import { drainNotificationTargetCleanup } from './target-cleanup.js';
|
|
3
|
+
import { closeSync, existsSync, lstatSync, openSync, readSync, readFileSync, readdirSync, statSync } from 'node:fs';
|
|
2
4
|
import { join } from 'node:path';
|
|
3
5
|
import { createHash } from 'node:crypto';
|
|
4
6
|
import { NotificationOutbox } from './outbox.js';
|
|
7
|
+
/** Unlike a UI inventory, unread classification must not treat unreadable records as absence. */
|
|
8
|
+
function taskRoleAssociations() {
|
|
9
|
+
const dir = roomsDir(), result = new Map();
|
|
10
|
+
let files;
|
|
11
|
+
try {
|
|
12
|
+
files = readdirSync(dir);
|
|
13
|
+
}
|
|
14
|
+
catch (error) {
|
|
15
|
+
if (error.code === 'ENOENT')
|
|
16
|
+
return result;
|
|
17
|
+
throw error;
|
|
18
|
+
}
|
|
19
|
+
for (const name of files.filter(name => name.endsWith('.json'))) {
|
|
20
|
+
const file = join(dir, name), stat = lstatSync(file);
|
|
21
|
+
if (!stat.isFile() || stat.isSymbolicLink() || stat.size > 1024 * 1024)
|
|
22
|
+
throw Error('Unreadable room association');
|
|
23
|
+
const room = JSON.parse(readFileSync(file, 'utf8'));
|
|
24
|
+
if (!room || typeof room.room_id !== 'string' || !Array.isArray(room.member_seats))
|
|
25
|
+
throw Error('Invalid room association');
|
|
26
|
+
if (room.task_id === undefined)
|
|
27
|
+
continue;
|
|
28
|
+
if (typeof room.task_id !== 'string' || !/^[A-Za-z0-9_-]{1,120}$/.test(room.task_id))
|
|
29
|
+
throw Error('Invalid task association');
|
|
30
|
+
for (const seat of room.member_seats) {
|
|
31
|
+
if (typeof seat.role_name !== 'string')
|
|
32
|
+
throw Error('Invalid member association');
|
|
33
|
+
if (result.has(seat.role_name) && result.get(seat.role_name) !== room.task_id)
|
|
34
|
+
throw Error('Ambiguous task association');
|
|
35
|
+
result.set(seat.role_name, room.task_id);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return result;
|
|
39
|
+
}
|
|
5
40
|
/** Read durable supervisor ledgers without restarting agents or attaching a
|
|
6
41
|
* conversation controller. Each tick reads at most 256 KiB per role, from its
|
|
7
42
|
* checkpoint; it never loads a complete transcript or requests model history. */
|
|
@@ -13,6 +48,7 @@ export class FleetNotificationProducer {
|
|
|
13
48
|
queues = new Map();
|
|
14
49
|
timer;
|
|
15
50
|
existing = new Set();
|
|
51
|
+
cleanup;
|
|
16
52
|
constructor(roots, stateDir, config, warn = () => { }) {
|
|
17
53
|
this.roots = roots;
|
|
18
54
|
this.stateDir = stateDir;
|
|
@@ -34,6 +70,49 @@ export class FleetNotificationProducer {
|
|
|
34
70
|
}
|
|
35
71
|
}
|
|
36
72
|
poll() {
|
|
73
|
+
this.cleanup ??= drainNotificationTargetCleanup(this.config, undefined, undefined, { beforeDelete: async (url) => {
|
|
74
|
+
const name = new URL(url, 'https://ours.invalid').searchParams.get('chat');
|
|
75
|
+
if (!name || !/^[A-Za-z0-9_-]+$/.test(name))
|
|
76
|
+
return false;
|
|
77
|
+
// A recreated or retained role still has a valid target. Filesystem errors cannot prove removal.
|
|
78
|
+
for (const root of this.roots) {
|
|
79
|
+
try {
|
|
80
|
+
lstatSync(join(root, name));
|
|
81
|
+
return false;
|
|
82
|
+
}
|
|
83
|
+
catch (error) {
|
|
84
|
+
if (error.code !== 'ENOENT')
|
|
85
|
+
throw error;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
for (const root of this.roots) {
|
|
89
|
+
const roleDir = join(root, name), queue = this.queues.get(roleDir);
|
|
90
|
+
if (queue) {
|
|
91
|
+
await queue.retire();
|
|
92
|
+
this.queues.delete(roleDir);
|
|
93
|
+
}
|
|
94
|
+
else {
|
|
95
|
+
const file = join(this.stateDir, createHash('sha256').update(roleDir).digest('hex') + '.json');
|
|
96
|
+
if (existsSync(file)) {
|
|
97
|
+
const queue = new NotificationOutbox(file, this.config, async () => { throw Error('retired target'); });
|
|
98
|
+
try {
|
|
99
|
+
await queue.retire();
|
|
100
|
+
}
|
|
101
|
+
finally {
|
|
102
|
+
await queue.close();
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return true;
|
|
108
|
+
} }).catch(() => { }).finally(() => { this.cleanup = undefined; });
|
|
109
|
+
let taskRoles;
|
|
110
|
+
try {
|
|
111
|
+
taskRoles = taskRoleAssociations();
|
|
112
|
+
}
|
|
113
|
+
catch {
|
|
114
|
+
this.warn('Notification task association unavailable; delivering unclassified events');
|
|
115
|
+
}
|
|
37
116
|
for (const root of this.roots)
|
|
38
117
|
for (const roleId of this.names(root)) {
|
|
39
118
|
const roleDir = join(root, roleId), dir = join(roleDir, '.conversation');
|
|
@@ -108,7 +187,7 @@ export class FleetNotificationProducer {
|
|
|
108
187
|
if (['message.chunk', 'message.replace'].includes(event.kind) && event.promptId && event.messageId && current.prompts.includes(prompt))
|
|
109
188
|
current.messages[prompt] = event.messageId;
|
|
110
189
|
if (event.kind === 'turn.completed' && event.promptId && current.prompts.includes(prompt)) {
|
|
111
|
-
if (!outbox.enqueue(`${roleId}:${event.sessionGeneration}:${event.seq}`, { url: `/fleet/chats?chat=${encodeURIComponent(roleId)}&detail=1#fleet-message-${encodeURIComponent(current.messages[prompt] ?? event.promptId)}` }))
|
|
190
|
+
if (!outbox.enqueue(`${roleId}:${event.sessionGeneration}:${event.seq}`, { ...(taskRoles ? { taskId: taskRoles.get(roleId) ?? null } : {}), url: `/fleet/chats?chat=${encodeURIComponent(roleId)}&detail=1#fleet-message-${encodeURIComponent(current.messages[prompt] ?? event.promptId)}` }))
|
|
112
191
|
break;
|
|
113
192
|
current.prompts = current.prompts.filter(p => p !== prompt);
|
|
114
193
|
delete current.messages[prompt];
|
|
@@ -125,5 +204,5 @@ export class FleetNotificationProducer {
|
|
|
125
204
|
}
|
|
126
205
|
}
|
|
127
206
|
async drain() { await Promise.all([...this.queues.values()].map(q => q.drain())); }
|
|
128
|
-
async close() { clearInterval(this.timer); await Promise.all([...this.queues.values()].map(q => q.close())); }
|
|
207
|
+
async close() { clearInterval(this.timer); await this.cleanup; await Promise.all([...this.queues.values()].map(q => q.close())); }
|
|
129
208
|
}
|
|
@@ -3,6 +3,7 @@ export interface NotificationInput {
|
|
|
3
3
|
title: string;
|
|
4
4
|
body: string;
|
|
5
5
|
url: string;
|
|
6
|
+
taskId?: string | null;
|
|
6
7
|
}
|
|
7
8
|
export interface ProducerConfig {
|
|
8
9
|
/** Service base URL. With a gateway credential it is the server gateway's notifications route. */
|
|
@@ -29,6 +30,7 @@ export declare class NotificationOutbox {
|
|
|
29
30
|
cursor?: number;
|
|
30
31
|
checkpoint?: unknown;
|
|
31
32
|
private running?;
|
|
33
|
+
private retired;
|
|
32
34
|
private timer;
|
|
33
35
|
constructor(file: string, config: ProducerConfig, project: (value: unknown, id: string) => Promise<NotificationInput>, warn?: (message: string) => void);
|
|
34
36
|
private persist;
|
|
@@ -37,6 +39,8 @@ export declare class NotificationOutbox {
|
|
|
37
39
|
enqueue(id: string, value: unknown, cursor?: number | undefined): boolean;
|
|
38
40
|
drain(): Promise<void>;
|
|
39
41
|
private drainOnce;
|
|
42
|
+
/** Wait for the current send before clearing queued sends and retiring its target. */
|
|
43
|
+
retire(): Promise<void>;
|
|
40
44
|
close(): Promise<void>;
|
|
41
45
|
}
|
|
42
46
|
export {};
|
|
@@ -66,6 +66,7 @@ export class NotificationOutbox {
|
|
|
66
66
|
cursor;
|
|
67
67
|
checkpoint;
|
|
68
68
|
running;
|
|
69
|
+
retired = false;
|
|
69
70
|
timer;
|
|
70
71
|
constructor(file, config, project, warn = () => { }) {
|
|
71
72
|
this.file = file;
|
|
@@ -106,6 +107,8 @@ export class NotificationOutbox {
|
|
|
106
107
|
advance(cursor) { this.persist(this.entries, cursor); }
|
|
107
108
|
saveCheckpoint(checkpoint) { this.persist(this.entries, this.cursor, checkpoint); }
|
|
108
109
|
enqueue(id, value, cursor = this.cursor) {
|
|
110
|
+
if (this.retired)
|
|
111
|
+
return false;
|
|
109
112
|
if (this.entries.some(e => e.id === id)) {
|
|
110
113
|
if (cursor !== this.cursor)
|
|
111
114
|
this.advance(cursor);
|
|
@@ -117,10 +120,14 @@ export class NotificationOutbox {
|
|
|
117
120
|
return true;
|
|
118
121
|
}
|
|
119
122
|
drain() {
|
|
123
|
+
if (this.retired)
|
|
124
|
+
return Promise.resolve();
|
|
120
125
|
return this.running ??= this.drainOnce().finally(() => { this.running = undefined; });
|
|
121
126
|
}
|
|
122
127
|
async drainOnce() {
|
|
123
128
|
for (const entry of [...this.entries].slice(0, 32)) {
|
|
129
|
+
if (this.retired)
|
|
130
|
+
break;
|
|
124
131
|
try {
|
|
125
132
|
const value = entry.payload ?? await this.project(entry.value, entry.id);
|
|
126
133
|
// Freeze the canonical projection before any network attempt. A crash
|
|
@@ -145,5 +152,7 @@ export class NotificationOutbox {
|
|
|
145
152
|
}
|
|
146
153
|
}
|
|
147
154
|
}
|
|
155
|
+
/** Wait for the current send before clearing queued sends and retiring its target. */
|
|
156
|
+
async retire() { this.retired = true; clearInterval(this.timer); await this.running; this.persist([]); }
|
|
148
157
|
async close() { clearInterval(this.timer); await this.running; }
|
|
149
158
|
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { type ProducerConfig } from './outbox.js';
|
|
2
|
+
/** Queue only after the owning lifecycle has established deletion, never on a failed inventory read. */
|
|
3
|
+
export declare function retireNotificationTarget(url: string, config?: ProducerConfig | undefined, dir?: string, request?: typeof fetch): Promise<void>;
|
|
4
|
+
/** Persisted retries also run from the ordinary web notification producer after restart. */
|
|
5
|
+
export declare function drainNotificationTargetCleanup(config: ProducerConfig, dir?: string, request?: typeof fetch, options?: {
|
|
6
|
+
retain?: boolean;
|
|
7
|
+
beforeDelete?: (url: string) => Promise<boolean>;
|
|
8
|
+
}): Promise<void>;
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { replaceFileAtomically } from '../atomic-file.js';
|
|
5
|
+
import { stateRoot } from '../paths.js';
|
|
6
|
+
import { producerConfig } from './outbox.js';
|
|
7
|
+
const cleanupDir = () => join(stateRoot(), 'notifications', 'removed-targets');
|
|
8
|
+
/** Queue only after the owning lifecycle has established deletion, never on a failed inventory read. */
|
|
9
|
+
export async function retireNotificationTarget(url, config = producerConfig(), dir = cleanupDir(), request = fetch) {
|
|
10
|
+
if (!config)
|
|
11
|
+
return;
|
|
12
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
13
|
+
replaceFileAtomically(join(dir, createHash('sha256').update(url).digest('hex') + '.json'), JSON.stringify({ url }) + '\n', 0o600);
|
|
14
|
+
await drainNotificationTargetCleanup(config, dir, request, { retain: true });
|
|
15
|
+
}
|
|
16
|
+
/** Persisted retries also run from the ordinary web notification producer after restart. */
|
|
17
|
+
export async function drainNotificationTargetCleanup(config, dir = cleanupDir(), request = fetch, options = {}) {
|
|
18
|
+
if (!existsSync(dir))
|
|
19
|
+
return;
|
|
20
|
+
for (const file of readdirSync(dir).filter(file => /^[a-f0-9]{64}\.json$/.test(file)).slice(0, 32)) {
|
|
21
|
+
try {
|
|
22
|
+
const path = join(dir, file), value = JSON.parse(readFileSync(path, 'utf8'));
|
|
23
|
+
if (typeof value.url !== 'string' || !value.url.startsWith('/fleet/chats?'))
|
|
24
|
+
continue;
|
|
25
|
+
if (options.beforeDelete && !(await options.beforeDelete(value.url))) {
|
|
26
|
+
rmSync(path, { force: true });
|
|
27
|
+
continue;
|
|
28
|
+
}
|
|
29
|
+
const response = await request(config.origin + '/api/v1/delete-target', { method: 'POST', redirect: 'error', signal: AbortSignal.timeout(10000),
|
|
30
|
+
headers: { ...(config.gatewayCredential ? { 'X-Ours-Api-Token': config.gatewayCredential, 'X-Ours-Notifications-Producer': config.token } : { Authorization: `Bearer ${config.token}` }), 'Content-Type': 'application/json' }, body: JSON.stringify(value) });
|
|
31
|
+
await response.body?.cancel();
|
|
32
|
+
if (!response.ok)
|
|
33
|
+
break;
|
|
34
|
+
if (!options.retain)
|
|
35
|
+
rmSync(path, { force: true });
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
break; /* No credentials or response bodies are included in logs. */
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
package/dist/ops.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { retireNotificationTarget } from './notifications/target-cleanup.js';
|
|
1
2
|
import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync, rmSync, unlinkSync, } from 'node:fs';
|
|
2
3
|
import { join } from 'node:path';
|
|
3
4
|
import { randomUUID, createHash } from 'node:crypto';
|
|
@@ -5,6 +6,7 @@ import { agentDir, watchdogsRoot } from './paths.js';
|
|
|
5
6
|
import { findRole } from './config.js';
|
|
6
7
|
import { getAdapter } from './harness/registry.js';
|
|
7
8
|
import { generateBriefing } from './briefing.js';
|
|
9
|
+
import { managedCliBriefing } from './managed-cli.js';
|
|
8
10
|
import { resetRestartLedger } from './runner.js';
|
|
9
11
|
import { archiveTempState, stopTempSupervisor, tempSupervisorLiveness, } from './temp-lifecycle.js';
|
|
10
12
|
import { realExec } from './exec.js';
|
|
@@ -28,7 +30,7 @@ export function applyRole(role, opts = {}) {
|
|
|
28
30
|
// brought up from so the supervised process can reload the SAME file instead of
|
|
29
31
|
// silently falling back to the default ~/fleet.yaml. Temp roles snapshot their
|
|
30
32
|
// whole resolved role into role.yaml instead and don't need this.
|
|
31
|
-
if (!opts.temp)
|
|
33
|
+
if (!opts.temp || opts.configPath)
|
|
32
34
|
writeFileSync(join(dir, '.config-path'), (opts.configPath ?? '') + '\n');
|
|
33
35
|
writeFileSync(join(dir, '.identity'), role.identity + '\n');
|
|
34
36
|
if (role.cwd)
|
|
@@ -43,6 +45,7 @@ export function applyRole(role, opts = {}) {
|
|
|
43
45
|
routinesPath: join(dir, 'ROUTINES.md'), briefingBody,
|
|
44
46
|
identityGuarantee: opts.identityGuarantee,
|
|
45
47
|
temporaryIdentity: opts.temp === true,
|
|
48
|
+
managedCli: managedCliBriefing(role, opts.configPath, opts.temp === true),
|
|
46
49
|
}));
|
|
47
50
|
if (opts.fresh)
|
|
48
51
|
for (const f of ['.booted', '.session-id', '.exit-status'])
|
|
@@ -255,6 +258,7 @@ export async function rmRole(cfg, name, deps) {
|
|
|
255
258
|
rmSync(agentDir(name), { recursive: true, force: true });
|
|
256
259
|
if (generatedSource && existsSync(role.sourceFile)) {
|
|
257
260
|
unlinkSync(role.sourceFile);
|
|
261
|
+
await retireNotificationTarget(`/fleet/chats?chat=${encodeURIComponent(name)}`).catch(() => { });
|
|
258
262
|
deps.log(`removed ${role.sourceFile}`);
|
|
259
263
|
}
|
|
260
264
|
deps.log(`removed '${name}' (its ours identity is left intact)`);
|
package/dist/resolved-plan.js
CHANGED
|
@@ -103,6 +103,7 @@ export function resolvedRolePlan(role, manifestDir = dirname(role.sourceFile)) {
|
|
|
103
103
|
isolation: role.isolation ?? null,
|
|
104
104
|
worklog: role.worklog ?? null,
|
|
105
105
|
authProxy: role.auth_proxy ?? null,
|
|
106
|
+
managedCli: role.managed_cli ?? [],
|
|
106
107
|
oversee: role.oversee ?? [],
|
|
107
108
|
harnessOptions: redactSensitive(role.harness_options ?? null),
|
|
108
109
|
env: {
|
package/dist/runner.js
CHANGED
|
@@ -2,6 +2,7 @@ import { createLayoutControl } from './rooms-tasks/layout-control.js';
|
|
|
2
2
|
import { TemporaryChatIdle } from './temp-idle.js';
|
|
3
3
|
import { prepareManagedAgent, releaseManagedAgent } from './agent-ours/service.js';
|
|
4
4
|
import { prepareManagedHarness } from './agent-ours/harness.js';
|
|
5
|
+
import { prepareManagedCliLaunch } from './managed-cli.js';
|
|
5
6
|
import { existsSync, readFileSync, writeFileSync, rmSync, mkdirSync, realpathSync } from 'node:fs';
|
|
6
7
|
import { join } from 'node:path';
|
|
7
8
|
import { fileURLToPath } from 'node:url';
|
|
@@ -522,7 +523,14 @@ export async function runOnce(name, opts = {}, partialDeps = {}) {
|
|
|
522
523
|
writeFileSync(bootedFile, `${new Date(deps.now()).toISOString()} ${mode}\n`);
|
|
523
524
|
const runCwd = role.cwd && existsSync(role.cwd) ? role.cwd : dir;
|
|
524
525
|
role = { ...role, monitor: { ...role.monitor, mode: 'fleet' } };
|
|
525
|
-
|
|
526
|
+
// Native policy for Fleet commands run from the harness command sandbox. This
|
|
527
|
+
// is the same step for configured, spawned and task-room agents alike.
|
|
528
|
+
const prep = prepareManagedCliLaunch(role, {
|
|
529
|
+
// A temporary agent has no config path threaded through its detached
|
|
530
|
+
// supervisor; spawn records the one it was created from beside its snapshot.
|
|
531
|
+
stateDir: dir, runCwd, configPath: configPath ?? resolveConfigPath(dir), temp,
|
|
532
|
+
prep: await adapter.prepareSession(role, { stateDir: dir, runCwd }), log: deps.log,
|
|
533
|
+
});
|
|
526
534
|
const sessionBackend = role.session ?? 'acp';
|
|
527
535
|
const sessionLabel = sessionBackend === 'acp' ? 'ACP' : 'Codex app-server';
|
|
528
536
|
let launch = adapter.agentSession.prepareLaunch(role, prep);
|
package/dist/spawn.js
CHANGED
|
@@ -358,7 +358,7 @@ async function spawnTempInner(o, preparedRole, binPath, launch, tx, onStage) {
|
|
|
358
358
|
if (role.roomMemberStartup)
|
|
359
359
|
role.roomMemberStartup = { ...role.roomMemberStartup, invite: '' };
|
|
360
360
|
onStage?.('writing_role');
|
|
361
|
-
const dir = applyRole(role, { temp: true, identityGuarantee: 'unverified' });
|
|
361
|
+
const dir = applyRole(role, { temp: true, identityGuarantee: 'unverified', configPath: o.configPath });
|
|
362
362
|
const provenance = buildProvenance({
|
|
363
363
|
role: o.name, lifetime: 'temporary', fleetVersion: VERSION,
|
|
364
364
|
settings: provenanceSettings(o, cfg.defaults, preparedRole.temporaryLoops),
|
|
@@ -6,3 +6,14 @@ export declare function readTunnelSetupStdin(input?: AsyncIterable<Buffer | stri
|
|
|
6
6
|
export declare function readTunnelSetupArgument(input: string): string;
|
|
7
7
|
/** Redeem the grant by HTTPS POST; long-lived connector credentials remain off argv and URLs. */
|
|
8
8
|
export declare function redeemTunnelSetup(input: string, request?: typeof fetch): Promise<WorkspacePayload>;
|
|
9
|
+
/** Inspect only public destination metadata; this never consumes a one-use grant. */
|
|
10
|
+
export declare function inspectTunnelSetup(input: string, request?: typeof fetch): Promise<{
|
|
11
|
+
workspaceId: string;
|
|
12
|
+
serverCid: string;
|
|
13
|
+
appOrigin: string;
|
|
14
|
+
} | undefined>;
|
|
15
|
+
export declare function verifyTunnelSetupTarget(target: {
|
|
16
|
+
workspaceId: string;
|
|
17
|
+
serverCid: string;
|
|
18
|
+
appOrigin: string;
|
|
19
|
+
} | undefined, payload: WorkspacePayload): void;
|
|
@@ -80,3 +80,45 @@ export async function redeemTunnelSetup(input, request = fetch) {
|
|
|
80
80
|
throw Error('Tunnel setup response origin mismatch');
|
|
81
81
|
return payload;
|
|
82
82
|
}
|
|
83
|
+
/** Inspect only public destination metadata; this never consumes a one-use grant. */
|
|
84
|
+
export async function inspectTunnelSetup(input, request = fetch) {
|
|
85
|
+
let version;
|
|
86
|
+
try {
|
|
87
|
+
version = JSON.parse(Buffer.from(input.trim(), 'base64url').toString()).version;
|
|
88
|
+
}
|
|
89
|
+
catch { }
|
|
90
|
+
if (version === 1) {
|
|
91
|
+
const p = decodeWorkspacePayload(input);
|
|
92
|
+
return { workspaceId: p.challenge.workspaceId, serverCid: p.serverCid, appOrigin: p.appOrigin };
|
|
93
|
+
}
|
|
94
|
+
const grant = decodeTunnelSetupGrant(input);
|
|
95
|
+
let response;
|
|
96
|
+
try {
|
|
97
|
+
response = await request(grant.appOrigin + '/account-api/workspace-install-inspect', { method: 'POST', redirect: 'error', credentials: 'omit', signal: AbortSignal.timeout(15000), headers: { Origin: grant.appOrigin, 'Content-Type': 'application/json' }, body: JSON.stringify({ code: grant.code }) });
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
throw Error('Could not inspect the account setup command; existing setup and grant preserved');
|
|
101
|
+
}
|
|
102
|
+
if (response.status === 404 || response.status === 405) {
|
|
103
|
+
await response.body?.cancel();
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
if (!response.ok) {
|
|
107
|
+
await response.body?.cancel();
|
|
108
|
+
throw Error('Setup command inspection failed; existing setup and grant preserved');
|
|
109
|
+
}
|
|
110
|
+
let target;
|
|
111
|
+
try {
|
|
112
|
+
target = await response.json();
|
|
113
|
+
}
|
|
114
|
+
catch {
|
|
115
|
+
throw Error('Invalid setup command inspection response');
|
|
116
|
+
}
|
|
117
|
+
if (!target || Object.keys(target).sort().join(',') !== 'serverCid,workspaceId' || typeof target.workspaceId !== 'string' || !/^[A-Za-z0-9_-]{43}$/.test(target.workspaceId) || typeof target.serverCid !== 'string' || !/^[a-f0-9]{64}$/i.test(target.serverCid))
|
|
118
|
+
throw Error('Invalid setup command inspection response');
|
|
119
|
+
return { ...target, appOrigin: grant.appOrigin };
|
|
120
|
+
}
|
|
121
|
+
export function verifyTunnelSetupTarget(target, payload) {
|
|
122
|
+
if (target && (target.workspaceId !== payload.challenge.workspaceId || target.serverCid.toUpperCase() !== payload.serverCid.toUpperCase() || target.appOrigin !== payload.appOrigin))
|
|
123
|
+
throw Error('Redeemed setup command differs from inspected destination; existing setup preserved');
|
|
124
|
+
}
|
|
@@ -25,5 +25,7 @@ export declare class WorkspaceDeviceStore {
|
|
|
25
25
|
valid(id: string): boolean;
|
|
26
26
|
list(): WorkspaceDevice[];
|
|
27
27
|
revoke(id: string): void;
|
|
28
|
+
/** Retire account links without changing the installation's stable host ID or other data. */
|
|
29
|
+
revokeAll(): void;
|
|
28
30
|
close(): void;
|
|
29
31
|
}
|
|
@@ -80,5 +80,18 @@ export class WorkspaceDeviceStore {
|
|
|
80
80
|
valid(id) { return Boolean(this.db.prepare('SELECT id FROM devices WHERE id=? AND revokedAt IS NULL AND expiresAt>?').get(id, this.now())); }
|
|
81
81
|
list() { return this.db.prepare('SELECT id,label,createdAt,lastUsedAt,expiresAt,revokedAt FROM devices ORDER BY createdAt DESC LIMIT 256').all().map(row => ({ id: String(row.id), label: String(row.label), createdAt: Number(row.createdAt), lastUsedAt: Number(row.lastUsedAt), expiresAt: Number(row.expiresAt), revokedAt: row.revokedAt === null ? null : Number(row.revokedAt) })); }
|
|
82
82
|
revoke(id) { this.db.prepare('UPDATE devices SET revokedAt=? WHERE id=? AND revokedAt IS NULL').run(this.now(), id); this.db.prepare('DELETE FROM enrollments WHERE issuer=?').run(id); }
|
|
83
|
+
/** Retire account links without changing the installation's stable host ID or other data. */
|
|
84
|
+
revokeAll() {
|
|
85
|
+
this.db.exec('BEGIN IMMEDIATE');
|
|
86
|
+
try {
|
|
87
|
+
this.db.prepare('UPDATE devices SET revokedAt=? WHERE revokedAt IS NULL').run(this.now());
|
|
88
|
+
this.db.exec('DELETE FROM enrollments');
|
|
89
|
+
this.db.exec('COMMIT');
|
|
90
|
+
}
|
|
91
|
+
catch (error) {
|
|
92
|
+
this.db.exec('ROLLBACK');
|
|
93
|
+
throw error;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
83
96
|
close() { this.db.close(); }
|
|
84
97
|
}
|
|
@@ -6,7 +6,7 @@ export interface WorkspacePayload {
|
|
|
6
6
|
rootName: string;
|
|
7
7
|
name: string;
|
|
8
8
|
surname: string;
|
|
9
|
-
connectorToken
|
|
9
|
+
connectorToken?: string;
|
|
10
10
|
invitation: string;
|
|
11
11
|
serverCid: string;
|
|
12
12
|
challenge: {
|
|
@@ -17,7 +17,11 @@ export interface WorkspacePayload {
|
|
|
17
17
|
};
|
|
18
18
|
}
|
|
19
19
|
export declare function readWorkspacePayload(file: string): WorkspacePayload;
|
|
20
|
-
export declare function decodeWorkspacePayload(input: string
|
|
20
|
+
export declare function decodeWorkspacePayload(input: string, options?: {
|
|
21
|
+
allowExpired?: boolean;
|
|
22
|
+
}): WorkspacePayload;
|
|
23
|
+
/** Credential-free renewal is allowed only on the host already holding this private connector. */
|
|
24
|
+
export declare function checkWorkspaceConnector(payload: WorkspacePayload): void;
|
|
21
25
|
type DaemonClient = Pick<OursClient, 'listIdentities' | 'chooseIdentity' | 'addContact' | 'listContacts' | 'sendCommand' | 'removeContact' | 'releaseLease' | 'close'>;
|
|
22
26
|
/** How tunnel setup reaches this host's daemon; replaced only by tests. */
|
|
23
27
|
export type AttachDaemonClient = (options: AttachOursClientOptions) => Promise<DaemonClient>;
|
|
@@ -29,6 +33,7 @@ export declare function checkWorkspaceConfiguration(configuration?: string, atta
|
|
|
29
33
|
export declare function enrollWorkspace(payload: WorkspacePayload, configuration?: string, options?: {
|
|
30
34
|
migrateAppOrigin?: boolean;
|
|
31
35
|
preserveProfile?: boolean;
|
|
36
|
+
replacingWorkspace?: string;
|
|
32
37
|
attach?: AttachDaemonClient;
|
|
33
38
|
contactWaitMs?: number;
|
|
34
39
|
afterInvitation?: () => void;
|
|
@@ -51,4 +56,27 @@ export declare function configureWorkspacePort(payload: Pick<WorkspacePayload, '
|
|
|
51
56
|
* so the setup can be finished later. A removal that fails is reported and does not undo the setup.
|
|
52
57
|
*/
|
|
53
58
|
export declare function confirmWorkspaceTarget(payload: Pick<WorkspacePayload, 'appOrigin' | 'challenge'>, hostWorkspaceId: string, rootCid: string, port: number, cleanupFailed?: (error: unknown) => void): Promise<void>;
|
|
59
|
+
/** Retire exactly the original registration using the same signed Human root that bound it. */
|
|
60
|
+
export declare function unregisterWorkspace(previous: {
|
|
61
|
+
workspaceId: string;
|
|
62
|
+
hostWorkspaceId: string;
|
|
63
|
+
appOrigin: string;
|
|
64
|
+
serverCid: string;
|
|
65
|
+
proofRootCid?: string;
|
|
66
|
+
}, payload: WorkspacePayload, operationNonce: string, attach?: AttachDaemonClient, options?: {
|
|
67
|
+
request?: typeof fetch;
|
|
68
|
+
waitMs?: number;
|
|
69
|
+
checkOnly?: boolean;
|
|
70
|
+
beforeSend?: () => void;
|
|
71
|
+
operationExpiresAt?: number;
|
|
72
|
+
onReceipt?: (status: {
|
|
73
|
+
deleted?: boolean;
|
|
74
|
+
retired?: boolean;
|
|
75
|
+
absent?: boolean;
|
|
76
|
+
rejected?: boolean;
|
|
77
|
+
expired?: boolean;
|
|
78
|
+
deadlineEnforced?: boolean;
|
|
79
|
+
oldBindingPresent?: boolean;
|
|
80
|
+
}) => void;
|
|
81
|
+
}): Promise<string>;
|
|
54
82
|
export {};
|