@ours.network/fleet 1.2.0-nightly.4 → 1.2.0-nightly.6
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 +16 -0
- package/dist/agent-ours/service.js +20 -8
- package/dist/application/task-room-service.js +30 -16
- package/dist/build-info.json +5 -4
- package/dist/capabilities.d.ts +2 -1
- package/dist/capabilities.js +2 -0
- package/dist/client-profile.d.ts +1 -0
- package/dist/client-profile.js +18 -3
- package/dist/docs.d.ts +2 -2
- package/dist/docs.js +3 -3
- package/dist/doctor.d.ts +1 -1
- package/dist/doctor.js +28 -9
- package/dist/harness/codex.d.ts +3 -2
- package/dist/harness/codex.js +4 -3
- package/dist/owner-channel/channel.d.ts +3 -0
- package/dist/owner-channel/channel.js +24 -4
- package/dist/rooms-tasks/cli.js +1 -1
- package/dist/rooms-tasks/close.d.ts +1 -0
- package/dist/rooms-tasks/close.js +42 -6
- package/dist/rooms-tasks/cowork-adapter.d.ts +4 -0
- package/dist/rooms-tasks/cowork-adapter.js +10 -2
- package/dist/rooms-tasks/cowork-http.d.ts +3 -0
- package/dist/rooms-tasks/cowork-http.js +70 -0
- package/dist/rooms-tasks/provision.d.ts +11 -0
- package/dist/rooms-tasks/provision.js +107 -7
- package/dist/rooms-tasks/room-state.js +6 -0
- package/dist/runner.js +22 -20
- package/package.json +5 -5
package/README.md
CHANGED
|
@@ -1694,3 +1694,19 @@ provisioning or automatic reverse-proxy configuration. Existing local HTTP and
|
|
|
1694
1694
|
SSH-tunnel profiles continue to work.
|
|
1695
1695
|
|
|
1696
1696
|
See [supervisor-owned ours and migration](docs/supervisor-ours.md) for the runtime contract and review build instructions.
|
|
1697
|
+
|
|
1698
|
+
### One-server gateway profile
|
|
1699
|
+
|
|
1700
|
+
A private client profile may include `serverUrl`, for example
|
|
1701
|
+
`https://ours.example/base`, together with `endpoint` set to
|
|
1702
|
+
`https://ours.example/base/daemon`, the existing `expectedInstanceId`, and the
|
|
1703
|
+
absolute private `credentialPath`. Fleet preserves that daemon prefix for doctor
|
|
1704
|
+
and monitoring and derives `/cowork/management/rpc` from `serverUrl` for room
|
|
1705
|
+
management. Requests use the issued server credential, reject redirects, have
|
|
1706
|
+
bounded bodies/deadlines, and never replay mutations. This capability is declared
|
|
1707
|
+
as `cowork.http-management-v1` in the built artifact.
|
|
1708
|
+
|
|
1709
|
+
Explicit Cowork socket, config or state-directory overrides continue to select
|
|
1710
|
+
local Unix management. Profiles without `serverUrl` retain legacy behavior. The
|
|
1711
|
+
HTTP API grants operator room authority; use the installer's supported private
|
|
1712
|
+
or authenticated external gateway entry, and keep its backend ports private.
|
|
@@ -11,19 +11,31 @@ import { atomicPrivateWrite, binderKey } from './state.js';
|
|
|
11
11
|
import { AgentOursRuntime } from './runtime.js';
|
|
12
12
|
import { startMcpEndpoint } from './mcp-endpoint.js';
|
|
13
13
|
export const privateRuntimeRoot = () => join(stateRoot(), 'private-ours');
|
|
14
|
+
function roomSecretPath(role) {
|
|
15
|
+
const startup = role.roomMemberStartup;
|
|
16
|
+
return join(privateRuntimeRoot(), 'room-inputs', binderKey(startup.room_identity_cid, JSON.stringify([role.name, startup.invite_id])) + '.json');
|
|
17
|
+
}
|
|
18
|
+
function matchesRoomSecret(secret, role) {
|
|
19
|
+
const startup = role.roomMemberStartup;
|
|
20
|
+
return secret.room_id === startup.room_id && secret.room_identity_cid === startup.room_identity_cid
|
|
21
|
+
&& secret.invite_id === startup.invite_id && secret.identity_name === role.identity
|
|
22
|
+
&& secret.role === startup.role;
|
|
23
|
+
}
|
|
14
24
|
/** Only trusted launch orchestration may write this descriptor, never the child. */
|
|
15
25
|
export function storeRoomSecret(role) {
|
|
16
26
|
const startup = role.roomMemberStartup;
|
|
17
27
|
if (!startup?.invite)
|
|
18
28
|
return;
|
|
29
|
+
if (startup.identity_name !== role.identity)
|
|
30
|
+
throw Error('ROOM_SECRET_MISMATCH');
|
|
19
31
|
const root = join(privateRuntimeRoot(), 'room-inputs');
|
|
20
32
|
mkdirSync(root, { recursive: true, mode: 0o700 });
|
|
21
|
-
|
|
33
|
+
// Failed attempts retain private evidence. A new invite must never overwrite
|
|
34
|
+
// or consume a previous attempt's descriptor.
|
|
35
|
+
const path = roomSecretPath(role);
|
|
22
36
|
if (existsSync(path)) {
|
|
23
37
|
const old = JSON.parse(readFileSync(path, 'utf8'));
|
|
24
|
-
if (old.
|
|
25
|
-
old.invite_id !== startup.invite_id ||
|
|
26
|
-
old.identity_name !== startup.identity_name)
|
|
38
|
+
if (!matchesRoomSecret(old, role) || old.invite !== startup.invite)
|
|
27
39
|
throw Error('ROOM_SECRET_COLLISION');
|
|
28
40
|
return;
|
|
29
41
|
}
|
|
@@ -144,7 +156,9 @@ export async function prepareManagedAgent(role, stateDir, temporary) {
|
|
|
144
156
|
let room;
|
|
145
157
|
if (role.roomMemberStartup) {
|
|
146
158
|
const startup = role.roomMemberStartup;
|
|
147
|
-
const
|
|
159
|
+
const legacyPath = join(root, 'room-inputs', binderKey(startup.room_identity_cid, role.name) + '.json');
|
|
160
|
+
const currentPath = roomSecretPath(role);
|
|
161
|
+
const path = existsSync(currentPath) ? currentPath : legacyPath;
|
|
148
162
|
const cowork = createCoworkAdapter();
|
|
149
163
|
room = {
|
|
150
164
|
id: startup.room_id,
|
|
@@ -153,9 +167,7 @@ export async function prepareManagedAgent(role, stateDir, temporary) {
|
|
|
153
167
|
action: startup.invite_id,
|
|
154
168
|
redeem: async (attached) => {
|
|
155
169
|
const secret = JSON.parse(readFileSync(path, 'utf8'));
|
|
156
|
-
if (secret
|
|
157
|
-
secret.invite_id !== startup.invite_id ||
|
|
158
|
-
secret.identity_name !== role.identity)
|
|
170
|
+
if (!matchesRoomSecret(secret, role))
|
|
159
171
|
throw Error('ROOM_SECRET_MISMATCH');
|
|
160
172
|
return attached.addContact({ invite: secret.invite });
|
|
161
173
|
},
|
|
@@ -136,7 +136,7 @@ export class TaskRoomApplicationService {
|
|
|
136
136
|
}
|
|
137
137
|
catch (error) {
|
|
138
138
|
if (error instanceof CoworkUnavailableError)
|
|
139
|
-
persistBlockTask(task.task_id, 'Cowork management
|
|
139
|
+
persistBlockTask(task.task_id, 'Cowork management is unavailable');
|
|
140
140
|
// Once the durable Room exists, provisioning errors are resumable
|
|
141
141
|
// saga state. Return that explicit state so the command can launch a
|
|
142
142
|
// continuation and report the durable in-progress outcome.
|
|
@@ -234,17 +234,23 @@ export class TaskRoomApplicationService {
|
|
|
234
234
|
const ready = task.state === 'active' && room?.state === 'active'
|
|
235
235
|
&& active === expected && launched === expected;
|
|
236
236
|
const blocker = task.outcome?.summary ?? task.blocked?.reason ?? room?.saga.error;
|
|
237
|
-
const nextAction =
|
|
238
|
-
? `
|
|
239
|
-
: room?.
|
|
240
|
-
? `
|
|
241
|
-
: room?.provisioning_detail === '
|
|
242
|
-
? `
|
|
243
|
-
: room?.provisioning_detail === '
|
|
244
|
-
? `
|
|
245
|
-
:
|
|
246
|
-
? `
|
|
247
|
-
:
|
|
237
|
+
const nextAction = task.terminal_intent
|
|
238
|
+
? `Complete the accepted ${task.terminal_intent.kind} operation; do not restart provisioning.`
|
|
239
|
+
: room?.state === 'closing' || room?.state === 'closed'
|
|
240
|
+
? `Complete room cleanup; do not restart provisioning.`
|
|
241
|
+
: room?.provisioning_detail === 'member_failed'
|
|
242
|
+
? `Inspect the failed launch, then run ours-fleet task start ${task.task_id}.`
|
|
243
|
+
: room?.provisioning_detail === 'waiting_owner_authorization'
|
|
244
|
+
? `Ensure ours-cowork 1.3.0 or newer is running and available, then run ours-fleet task start ${task.task_id}.`
|
|
245
|
+
: room?.provisioning_detail === 'waiting_owner_invite'
|
|
246
|
+
? `Rotate rooms.owner.public_invite, then run ours-fleet task start ${task.task_id}.`
|
|
247
|
+
: room?.provisioning_detail === 'owner_cid_mismatch'
|
|
248
|
+
? `Verify rooms.owner.expected_cid, rotate the Owner invite, then run ours-fleet task start ${task.task_id}.`
|
|
249
|
+
: room?.provisioning_detail === 'waiting_cowork'
|
|
250
|
+
? `Restore ours-cowork, then run ours-fleet task start ${task.task_id}.`
|
|
251
|
+
: failed
|
|
252
|
+
? `Correct the blocker, then run ours-fleet task start ${task.task_id}.`
|
|
253
|
+
: undefined;
|
|
248
254
|
return {
|
|
249
255
|
kind: failed ? 'failed' : ready ? 'ready' : 'in_progress', task, room,
|
|
250
256
|
launch: {
|
|
@@ -410,7 +416,9 @@ export class TaskRoomApplicationService {
|
|
|
410
416
|
let task = readTask(input.taskId);
|
|
411
417
|
let room = task.room_id ? getRoomRecord(task.room_id) : undefined;
|
|
412
418
|
const issues = [];
|
|
413
|
-
if (task.state !== 'provisioning'
|
|
419
|
+
if (task.state !== 'provisioning' || task.terminal_intent
|
|
420
|
+
|| room?.state === 'closing' || room?.state === 'closed'
|
|
421
|
+
|| room?.provisioning_detail === 'member_failed')
|
|
414
422
|
return {
|
|
415
423
|
kind: 'no_op', task, room, issues,
|
|
416
424
|
};
|
|
@@ -784,7 +792,7 @@ export class TaskRoomApplicationService {
|
|
|
784
792
|
}
|
|
785
793
|
catch (error) {
|
|
786
794
|
if (error instanceof CoworkUnavailableError)
|
|
787
|
-
persistBlockTask(task.task_id, 'Cowork management
|
|
795
|
+
persistBlockTask(task.task_id, 'Cowork management is unavailable');
|
|
788
796
|
const current = readTask(task.task_id);
|
|
789
797
|
if (!current.room_id || !getRoomRecord(current.room_id))
|
|
790
798
|
throw error;
|
|
@@ -802,7 +810,7 @@ export class TaskRoomApplicationService {
|
|
|
802
810
|
}
|
|
803
811
|
catch (error) {
|
|
804
812
|
if (error instanceof CoworkUnavailableError)
|
|
805
|
-
persistBlockTask(task.task_id, 'Cowork management
|
|
813
|
+
persistBlockTask(task.task_id, 'Cowork management is unavailable');
|
|
806
814
|
task = readTask(task.task_id);
|
|
807
815
|
return { task, status: 'in_progress' };
|
|
808
816
|
}
|
|
@@ -820,7 +828,7 @@ export class TaskRoomApplicationService {
|
|
|
820
828
|
}
|
|
821
829
|
catch (error) {
|
|
822
830
|
if (error instanceof CoworkUnavailableError)
|
|
823
|
-
persistBlockTask(task.task_id, 'Cowork management
|
|
831
|
+
persistBlockTask(task.task_id, 'Cowork management is unavailable');
|
|
824
832
|
task = readTask(task.task_id);
|
|
825
833
|
}
|
|
826
834
|
}
|
|
@@ -878,11 +886,17 @@ export class TaskRoomApplicationService {
|
|
|
878
886
|
if (fresh.deletion?.status === 'pending')
|
|
879
887
|
throw new TaskRoomApplicationError('task_deleting', `task ${task.task_id} is pending deletion`, { task: task.task_id });
|
|
880
888
|
}
|
|
889
|
+
const requiredRoles = new Map();
|
|
890
|
+
if (attachOwner)
|
|
891
|
+
requiredRoles.set(rooms.owner.role, 1);
|
|
892
|
+
for (const member of launchTemplate?.members ?? [])
|
|
893
|
+
requiredRoles.set(member.role, (requiredRoles.get(member.role) ?? 0) + member.count);
|
|
881
894
|
const created = await cowork.createRoom({
|
|
882
895
|
room_name: roomName, goal: task.goal?.trim() || task.title,
|
|
883
896
|
briefing: task.brief?.trim() || launchTemplate?.contract?.trim() || task.goal?.trim() || task.title,
|
|
884
897
|
quiet_membership: launchTemplate?.room?.quiet_membership,
|
|
885
898
|
anonymous: policy.anonymous,
|
|
899
|
+
activation_requirements: [...requiredRoles].map(([role, count]) => ({ role, count })),
|
|
886
900
|
});
|
|
887
901
|
const record = createRoomRecord({
|
|
888
902
|
room_id: created.room_id, room_name: roomName, room_identity_cid: created.identity_cid,
|
package/dist/build-info.json
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
{
|
|
2
|
-
"version": "1.2.0-nightly.
|
|
3
|
-
"buildId": "
|
|
4
|
-
"commit": "
|
|
2
|
+
"version": "1.2.0-nightly.6",
|
|
3
|
+
"buildId": "2075277a291e",
|
|
4
|
+
"commit": "01660e304fff7ee37784669ec601299c92df4912",
|
|
5
5
|
"dirty": true,
|
|
6
|
-
"builtAt": "2026-09-
|
|
6
|
+
"builtAt": "2026-09-23T10:49:32.469Z",
|
|
7
7
|
"capabilities": [
|
|
8
|
+
"cowork.http-management-v1",
|
|
8
9
|
"monitor.interrupt.after_tool"
|
|
9
10
|
]
|
|
10
11
|
}
|
package/dist/capabilities.d.ts
CHANGED
|
@@ -14,7 +14,8 @@
|
|
|
14
14
|
*/
|
|
15
15
|
/** `monitor.interrupt: after_tool` — cancel at the next tool boundary (#67). */
|
|
16
16
|
export declare const CAP_MONITOR_INTERRUPT_AFTER_TOOL = "monitor.interrupt.after_tool";
|
|
17
|
-
export declare const
|
|
17
|
+
export declare const CAP_HTTP_COWORK = "cowork.http-management-v1";
|
|
18
|
+
export declare const CAPABILITIES: readonly ["cowork.http-management-v1", "monitor.interrupt.after_tool"];
|
|
18
19
|
export type Capability = (typeof CAPABILITIES)[number];
|
|
19
20
|
/** Does this build declare `token`? */
|
|
20
21
|
export declare const hasCapability: (token: string) => boolean;
|
package/dist/capabilities.js
CHANGED
|
@@ -14,7 +14,9 @@
|
|
|
14
14
|
*/
|
|
15
15
|
/** `monitor.interrupt: after_tool` — cancel at the next tool boundary (#67). */
|
|
16
16
|
export const CAP_MONITOR_INTERRUPT_AFTER_TOOL = 'monitor.interrupt.after_tool';
|
|
17
|
+
export const CAP_HTTP_COWORK = 'cowork.http-management-v1';
|
|
17
18
|
export const CAPABILITIES = [
|
|
19
|
+
CAP_HTTP_COWORK,
|
|
18
20
|
CAP_MONITOR_INTERRUPT_AFTER_TOOL,
|
|
19
21
|
];
|
|
20
22
|
/** Does this build declare `token`? */
|
package/dist/client-profile.d.ts
CHANGED
|
@@ -3,6 +3,7 @@ export interface ExplicitClientProfile {
|
|
|
3
3
|
readonly expectedInstanceId: string;
|
|
4
4
|
readonly credentialPath: string;
|
|
5
5
|
readonly configPath: string;
|
|
6
|
+
readonly serverUrl?: string;
|
|
6
7
|
}
|
|
7
8
|
export declare class ClientProfileError extends Error {
|
|
8
9
|
constructor(message: string);
|
package/dist/client-profile.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { normalizeDaemonEndpoint } from '@ours.network/sdk/client';
|
|
1
2
|
import { lstatSync, readFileSync, statSync } from 'node:fs';
|
|
2
3
|
import { homedir } from 'node:os';
|
|
3
4
|
import { isAbsolute, join } from 'node:path';
|
|
@@ -97,17 +98,31 @@ export function readClientProfile(env) {
|
|
|
97
98
|
catch {
|
|
98
99
|
throw invalid(configPath, 'has an invalid endpoint');
|
|
99
100
|
}
|
|
100
|
-
if ((url.protocol !== 'http:' && url.protocol !== 'https:') || url.username || url.password
|
|
101
|
+
if ((url.protocol !== 'http:' && url.protocol !== 'https:') || url.username || url.password
|
|
101
102
|
|| url.search || url.hash)
|
|
102
|
-
throw invalid(configPath, 'endpoint must be an HTTP or HTTPS
|
|
103
|
+
throw invalid(configPath, 'endpoint must be an HTTP or HTTPS base URL');
|
|
103
104
|
const expectedInstanceId = row.expectedInstanceId.trim();
|
|
104
105
|
if (!LOWERCASE_UUID.test(expectedInstanceId))
|
|
105
106
|
throw invalid(configPath, 'expectedInstanceId must be a lowercase UUID');
|
|
106
107
|
const credentialPath = row.credentialPath.trim();
|
|
107
108
|
if (!isAbsolute(credentialPath))
|
|
108
109
|
throw invalid(configPath, 'credentialPath must be absolute');
|
|
110
|
+
let serverUrl;
|
|
111
|
+
if (Object.hasOwn(row, 'serverUrl')) {
|
|
112
|
+
if (typeof row.serverUrl !== 'string' || /[\s\\?#]/.test(row.serverUrl))
|
|
113
|
+
throw invalid(configPath, 'serverUrl must be a safe HTTP or HTTPS base URL');
|
|
114
|
+
try {
|
|
115
|
+
serverUrl = normalizeDaemonEndpoint(row.serverUrl);
|
|
116
|
+
}
|
|
117
|
+
catch {
|
|
118
|
+
throw invalid(configPath, 'serverUrl must be a safe HTTP or HTTPS base URL');
|
|
119
|
+
}
|
|
120
|
+
if (normalizeDaemonEndpoint(endpoint) !== serverUrl + '/daemon')
|
|
121
|
+
throw invalid(configPath, 'endpoint must select serverUrl/daemon');
|
|
122
|
+
}
|
|
109
123
|
return Object.freeze({
|
|
110
|
-
|
|
124
|
+
...(serverUrl === undefined ? {} : { serverUrl }),
|
|
125
|
+
endpoint: normalizeDaemonEndpoint(endpoint), expectedInstanceId, credentialPath, configPath,
|
|
111
126
|
});
|
|
112
127
|
}
|
|
113
128
|
export function clientProfileKey(profile) {
|
package/dist/docs.d.ts
CHANGED
|
@@ -17,8 +17,8 @@ export declare const AI_DOCS = "# ours-fleet reference\n\nours-fleet runs persis
|
|
|
17
17
|
* \`forbidden\` is the more important half. The old skills prescribed
|
|
18
18
|
* \`--approval ask --filesystem workspace --unattended deny\` as a blanket
|
|
19
19
|
* default while also telling the agent to stop at a failed doctor check — and
|
|
20
|
-
* that combination is exactly what \`doctor\` FAILS, because \`ask\`
|
|
21
|
-
* unattended
|
|
20
|
+
* that combination is exactly what \`doctor\` FAILS, because \`ask\` cannot
|
|
21
|
+
* guarantee unattended capabilities and \`deny\` makes the shortfall
|
|
22
22
|
* fatal. Following the skill produced a role the CLI then refused.
|
|
23
23
|
*/
|
|
24
24
|
export declare const SPAWN_SKILL_CONTRACT: {
|
package/dist/docs.js
CHANGED
|
@@ -1032,8 +1032,8 @@ reconcile explicitly; no chain preserves detection-only behavior.
|
|
|
1032
1032
|
* \`forbidden\` is the more important half. The old skills prescribed
|
|
1033
1033
|
* \`--approval ask --filesystem workspace --unattended deny\` as a blanket
|
|
1034
1034
|
* default while also telling the agent to stop at a failed doctor check — and
|
|
1035
|
-
* that combination is exactly what \`doctor\` FAILS, because \`ask\`
|
|
1036
|
-
* unattended
|
|
1035
|
+
* that combination is exactly what \`doctor\` FAILS, because \`ask\` cannot
|
|
1036
|
+
* guarantee unattended capabilities and \`deny\` makes the shortfall
|
|
1037
1037
|
* fatal. Following the skill produced a role the CLI then refused.
|
|
1038
1038
|
*/
|
|
1039
1039
|
export const SPAWN_SKILL_CONTRACT = {
|
|
@@ -1062,7 +1062,7 @@ export const SPAWN_SKILL_CONTRACT = {
|
|
|
1062
1062
|
*/
|
|
1063
1063
|
forbidden: [
|
|
1064
1064
|
// The contradictory blanket default both variants used to prescribe.
|
|
1065
|
-
// `ask`
|
|
1065
|
+
// `ask` cannot guarantee unattended capabilities, and `deny` makes the
|
|
1066
1066
|
// shortfall a doctor FAILURE — so the skill told you to build a role the
|
|
1067
1067
|
// CLI then refused, in the same breath as telling you to trust doctor.
|
|
1068
1068
|
'--approval ask --filesystem workspace --unattended deny',
|
package/dist/doctor.d.ts
CHANGED
|
@@ -3,7 +3,7 @@ import { type Exec } from './exec.js';
|
|
|
3
3
|
import type { YamlMode } from './config-yaml.js';
|
|
4
4
|
import { type FetchLike } from './monitor.js';
|
|
5
5
|
import type { PrereqReport } from './harness/types.js';
|
|
6
|
-
type DoctorDaemonClient = Pick<OursClient, 'version'>;
|
|
6
|
+
type DoctorDaemonClient = Pick<OursClient, 'version' | 'close'>;
|
|
7
7
|
type AttachDoctorDaemon = (options: AttachOursClientOptions) => Promise<DoctorDaemonClient>;
|
|
8
8
|
/** Host-level + per-harness prerequisite report with actionable messages. */
|
|
9
9
|
export declare function doctor(opts?: {
|
package/dist/doctor.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { join } from 'node:path';
|
|
2
|
+
import { userInfo, homedir } from 'node:os';
|
|
2
3
|
import { existsSync, readFileSync } from 'node:fs';
|
|
3
4
|
import { attachOursClient, } from '@ours.network/sdk/client';
|
|
4
5
|
import { realExec } from './exec.js';
|
|
@@ -272,21 +273,39 @@ export async function doctor(opts = {}, exec = realExec, platform = process.plat
|
|
|
272
273
|
}
|
|
273
274
|
catch { /* state dir may not exist yet */ }
|
|
274
275
|
}
|
|
276
|
+
let managedSelection = false;
|
|
275
277
|
try {
|
|
276
|
-
|
|
277
|
-
|
|
278
|
+
// Mark explicit selection before validation so a damaged managed profile does
|
|
279
|
+
// not produce advice to start an unrelated local daemon.
|
|
280
|
+
managedSelection = process.env.OURS_CONFIG !== undefined
|
|
281
|
+
|| existsSync(join(process.env.HOME || homedir(), '.ours-client', 'profile.json'));
|
|
282
|
+
const selected = readClientProfile(process.env);
|
|
283
|
+
managedSelection = selected !== undefined;
|
|
284
|
+
const client = await attachDaemon(selected ? {
|
|
285
|
+
endpoint: selected.endpoint, expectedInstanceId: selected.expectedInstanceId,
|
|
286
|
+
credentialPath: selected.credentialPath, env: {}, sessionMode: 'external',
|
|
278
287
|
leaseToken: `ours-fleet-doctor-${process.pid}`,
|
|
279
|
-
|
|
288
|
+
} : {
|
|
289
|
+
env: process.env, leaseToken: `ours-fleet-doctor-${process.pid}`, clientPid: process.pid,
|
|
280
290
|
});
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
291
|
+
let daemonVersion;
|
|
292
|
+
try {
|
|
293
|
+
const info = await client.version();
|
|
294
|
+
if (info.name !== 'ours' || typeof info.version !== 'string')
|
|
295
|
+
throw new Error('the selected endpoint did not return a valid ours daemon identity');
|
|
296
|
+
daemonVersion = info.version;
|
|
297
|
+
}
|
|
298
|
+
finally {
|
|
299
|
+
await client.close();
|
|
300
|
+
}
|
|
301
|
+
checks.push({ name: 'ours daemon', ok: true, detail: `running (${daemonVersion})` });
|
|
285
302
|
}
|
|
286
303
|
catch (error) {
|
|
287
304
|
checks.push({
|
|
288
305
|
name: 'ours daemon', ok: false,
|
|
289
|
-
detail:
|
|
306
|
+
detail: (managedSelection
|
|
307
|
+
? 'selected client profile failed — check its endpoint, instance and issued credential '
|
|
308
|
+
: 'not reachable through the SDK — start it with: ours-daemon start ')
|
|
290
309
|
+ `[${error?.message ?? String(error)}]`,
|
|
291
310
|
});
|
|
292
311
|
}
|
package/dist/harness/codex.d.ts
CHANGED
|
@@ -6,8 +6,9 @@ import type { AcpSessionTransport } from './acp-session-transport.js';
|
|
|
6
6
|
import type { CodexAppServerSessionTransport } from './codex-session.js';
|
|
7
7
|
/**
|
|
8
8
|
* What an unattended role can actually do under Codex's native settings.
|
|
9
|
-
* `on-request` and `untrusted`
|
|
10
|
-
*
|
|
9
|
+
* `on-request` and `untrusted` can ask even for startup file reads (for example,
|
|
10
|
+
* shell reads or files outside the sandbox). Without a controller the request
|
|
11
|
+
* waits or is denied according to policy. Neither guarantees `read-state`.
|
|
11
12
|
*/
|
|
12
13
|
export declare function codexCapabilities(approval: string, sandbox: string): UnattendedCapability[];
|
|
13
14
|
/** Defense in depth for callers which launch a role after validation was bypassed. */
|
package/dist/harness/codex.js
CHANGED
|
@@ -25,12 +25,13 @@ const CODEX_PROXY_REAL_PATH_ENV = 'OURS_FLEET_REAL_CODEX_PATH';
|
|
|
25
25
|
const CODEX_PROXY_MANIFEST_ENV = 'OURS_FLEET_CODEX_ACP_MANIFEST';
|
|
26
26
|
/**
|
|
27
27
|
* What an unattended role can actually do under Codex's native settings.
|
|
28
|
-
* `on-request` and `untrusted`
|
|
29
|
-
*
|
|
28
|
+
* `on-request` and `untrusted` can ask even for startup file reads (for example,
|
|
29
|
+
* shell reads or files outside the sandbox). Without a controller the request
|
|
30
|
+
* waits or is denied according to policy. Neither guarantees `read-state`.
|
|
30
31
|
*/
|
|
31
32
|
export function codexCapabilities(approval, sandbox) {
|
|
32
33
|
if (approval !== 'never')
|
|
33
|
-
return [
|
|
34
|
+
return [];
|
|
34
35
|
const caps = ['read-state', 'messaging', 'monitor', 'status-commands'];
|
|
35
36
|
if (sandbox !== 'read-only')
|
|
36
37
|
caps.push('write-state', 'workspace-edit');
|
|
@@ -14,6 +14,8 @@ export interface OwnerChannelOptions {
|
|
|
14
14
|
harness: string;
|
|
15
15
|
config: OwnerChannelConfig;
|
|
16
16
|
session: AgentSession;
|
|
17
|
+
/** Ordinary owner input must not cancel the initial briefing turn. */
|
|
18
|
+
startupPending?: () => boolean;
|
|
17
19
|
stateDir: string;
|
|
18
20
|
env?: Record<string, string>;
|
|
19
21
|
log(line: string): void;
|
|
@@ -269,6 +271,7 @@ export declare class OwnerChannel implements OwnerChannelHandle {
|
|
|
269
271
|
private attachmentGroups;
|
|
270
272
|
private handleAttachmentGroup;
|
|
271
273
|
private handle;
|
|
274
|
+
private ownerPromptPolicy;
|
|
272
275
|
/**
|
|
273
276
|
* Deterministic command path: the message never becomes an agent prompt.
|
|
274
277
|
* Authorization already happened — the managed-agent relay branch and the
|
|
@@ -1123,6 +1123,13 @@ export class OwnerChannel {
|
|
|
1123
1123
|
const retrieved = unread.length
|
|
1124
1124
|
? parseRetrievedAttachments(await this.client.getFiles(unread.map(file => file.wireId)), unread)
|
|
1125
1125
|
: [];
|
|
1126
|
+
// getFiles returns daemon-local paths, which need not exist in Fleet's
|
|
1127
|
+
// filesystem (for example with an HTTP daemon in a container). Fetch
|
|
1128
|
+
// through the bound client and keep the daemon's integrity metadata for
|
|
1129
|
+
// admission below; never trust or remap the returned filesystem path.
|
|
1130
|
+
for (const file of retrieved) {
|
|
1131
|
+
file.path = await writeRecoveredAttachment(requestDir, file.wireId, await this.client.fetchFile(file.wireId));
|
|
1132
|
+
}
|
|
1126
1133
|
for (const file of historyRecovered) {
|
|
1127
1134
|
if (!group.recovery)
|
|
1128
1135
|
throw new Error('unexpected read attachment without recovery route');
|
|
@@ -1136,8 +1143,7 @@ export class OwnerChannel {
|
|
|
1136
1143
|
await mkdir(outbox, { recursive: true, mode: 0o700 });
|
|
1137
1144
|
const activityCursor = this.latestEventSeq(this.options.session.eventsSince(0));
|
|
1138
1145
|
const queued = await queueSessionPrompt(this.options.session, this.ownerAttachmentPrompt(sender, originWireId, requestId, admitted, group.caption), {
|
|
1139
|
-
|
|
1140
|
-
...(this.options.config.interrupt ? { interruptSource: 'owner' } : {}),
|
|
1146
|
+
...this.ownerPromptPolicy(),
|
|
1141
1147
|
origin: { kind: 'owner', requestId,
|
|
1142
1148
|
...(group.caption ? { displayText: String(group.caption.text ?? '') } : {}) },
|
|
1143
1149
|
});
|
|
@@ -1263,8 +1269,7 @@ export class OwnerChannel {
|
|
|
1263
1269
|
const activityCursor = this.latestEventSeq(this.options.session.eventsSince(0));
|
|
1264
1270
|
try {
|
|
1265
1271
|
queued = await queueSessionPrompt(this.options.session, this.ownerPrompt(sender, text, wireId), {
|
|
1266
|
-
|
|
1267
|
-
...(this.options.config.interrupt ? { interruptSource: 'owner' } : {}),
|
|
1272
|
+
...this.ownerPromptPolicy(),
|
|
1268
1273
|
origin: { kind: 'owner', requestId, displayText: text },
|
|
1269
1274
|
});
|
|
1270
1275
|
}
|
|
@@ -1309,6 +1314,14 @@ export class OwnerChannel {
|
|
|
1309
1314
|
this.completionTasks.add(task);
|
|
1310
1315
|
return true;
|
|
1311
1316
|
}
|
|
1317
|
+
ownerPromptPolicy() {
|
|
1318
|
+
if (this.options.startupPending?.())
|
|
1319
|
+
return { interrupt: false, steer: true };
|
|
1320
|
+
return {
|
|
1321
|
+
interrupt: this.options.config.interrupt,
|
|
1322
|
+
...(this.options.config.interrupt ? { interruptSource: 'owner' } : {}),
|
|
1323
|
+
};
|
|
1324
|
+
}
|
|
1312
1325
|
/**
|
|
1313
1326
|
* Deterministic command path: the message never becomes an agent prompt.
|
|
1314
1327
|
* Authorization already happened — the managed-agent relay branch and the
|
|
@@ -1762,6 +1775,13 @@ export class OwnerChannel {
|
|
|
1762
1775
|
const retrieved = unread.length
|
|
1763
1776
|
? parseRetrievedAttachments(await this.client.getFiles(unread.map(file => file.wireId)), unread)
|
|
1764
1777
|
: [];
|
|
1778
|
+
// getFiles returns daemon-local paths, which need not exist in Fleet's
|
|
1779
|
+
// filesystem (for example with an HTTP daemon in a container). Fetch
|
|
1780
|
+
// through the bound client and keep the daemon's integrity metadata for
|
|
1781
|
+
// admission below; never trust or remap the returned filesystem path.
|
|
1782
|
+
for (const file of retrieved) {
|
|
1783
|
+
file.path = await writeRecoveredAttachment(requestDir, file.wireId, await this.client.fetchFile(file.wireId));
|
|
1784
|
+
}
|
|
1765
1785
|
for (const file of historyRecovered) {
|
|
1766
1786
|
if (!group.recovery)
|
|
1767
1787
|
throw new Error('unexpected read attachment without recovery route');
|
package/dist/rooms-tasks/cli.js
CHANGED
|
@@ -1152,7 +1152,7 @@ export function registerTaskCommands(parent, cOpt) {
|
|
|
1152
1152
|
const app = taskRoomService(opts.configuration);
|
|
1153
1153
|
for (;;) {
|
|
1154
1154
|
const before = app.taskProvisioningOutcome(id);
|
|
1155
|
-
if (before.kind !== 'in_progress') {
|
|
1155
|
+
if (before.kind !== 'in_progress' || before.next_action) {
|
|
1156
1156
|
await presentDetachedProvisioningOutcome(before);
|
|
1157
1157
|
return;
|
|
1158
1158
|
}
|
|
@@ -2,6 +2,7 @@ import { withFileLock } from '../atomic-file.js';
|
|
|
2
2
|
import { type TempLifecycleDeps } from '../temp-lifecycle.js';
|
|
3
3
|
import { type CoworkAdapter } from './cowork-adapter.js';
|
|
4
4
|
import type { RoomMemberSeat, RoomOrchestrationRecord } from './types.js';
|
|
5
|
+
export declare function roomCloseLockPath(roomId: string): string;
|
|
5
6
|
export interface RoomCloseDeps {
|
|
6
7
|
inspectMember?(seat: RoomMemberSeat): Promise<{
|
|
7
8
|
launchId: string;
|
|
@@ -4,13 +4,14 @@ import { randomUUID } from 'node:crypto';
|
|
|
4
4
|
import { attachOursClient } from '@ours.network/sdk/client';
|
|
5
5
|
import { withFileLock } from '../atomic-file.js';
|
|
6
6
|
import { agentDir, stateRoot } from '../paths.js';
|
|
7
|
+
import { readClientProfile } from '../client-profile.js';
|
|
7
8
|
import { readTempSupervisor, secureStoppedTempArchive, stopTempSupervisor, tempSupervisorLiveness, } from '../temp-lifecycle.js';
|
|
8
9
|
import { CoworkProtocolError } from './cowork-adapter.js';
|
|
9
10
|
import { advanceMemberRetirement, advanceRoomClose, beginRoomClose, closeRoom, deleteRoomRecord, getRoomRecord, listRoomRecords, setRoomCloseError, } from './room-state.js';
|
|
10
11
|
const CLOSE_LOCK_STALE_MS = 5 * 60_000;
|
|
11
12
|
const STOP_POLLS = 50;
|
|
12
13
|
const STOP_POLL_MS = 100;
|
|
13
|
-
function roomCloseLockPath(roomId) {
|
|
14
|
+
export function roomCloseLockPath(roomId) {
|
|
14
15
|
return join(stateRoot(), 'locks', 'room-close', encodeURIComponent(roomId));
|
|
15
16
|
}
|
|
16
17
|
function errorText(error) {
|
|
@@ -54,16 +55,28 @@ export async function waitForLivenessAbsent(role, launchId, lifecycleDeps = {})
|
|
|
54
55
|
throw new Error(`temporary role '${role}' did not reach proven stopped liveness`);
|
|
55
56
|
}
|
|
56
57
|
async function withIdentityClient(work) {
|
|
57
|
-
const
|
|
58
|
+
const profile = readClientProfile(process.env);
|
|
59
|
+
const leaseToken = `ours-fleet-room-close-${process.pid}-${randomUUID()}`;
|
|
60
|
+
const client = await attachOursClient(profile ? {
|
|
61
|
+
endpoint: profile.endpoint,
|
|
62
|
+
expectedInstanceId: profile.expectedInstanceId,
|
|
63
|
+
credentialPath: profile.credentialPath,
|
|
64
|
+
sessionMode: 'external', env: {}, leaseToken,
|
|
65
|
+
} : {
|
|
58
66
|
env: process.env,
|
|
59
|
-
leaseToken
|
|
67
|
+
leaseToken,
|
|
60
68
|
clientPid: process.pid,
|
|
61
69
|
});
|
|
62
70
|
try {
|
|
63
71
|
return await work(client);
|
|
64
72
|
}
|
|
65
73
|
finally {
|
|
66
|
-
|
|
74
|
+
try {
|
|
75
|
+
await client.releaseLease().catch(() => { });
|
|
76
|
+
}
|
|
77
|
+
finally {
|
|
78
|
+
await client.close();
|
|
79
|
+
}
|
|
67
80
|
}
|
|
68
81
|
}
|
|
69
82
|
function listedIdentity(rows, name) {
|
|
@@ -106,11 +119,36 @@ export async function removeExactMemberIdentity(seat) {
|
|
|
106
119
|
}
|
|
107
120
|
});
|
|
108
121
|
}
|
|
122
|
+
async function assertMemberIdentityAbsent(seat) {
|
|
123
|
+
await withIdentityClient(async (client) => {
|
|
124
|
+
const rows = await client.listIdentities();
|
|
125
|
+
if (rows.some(row => row.name === seat.role_name ||
|
|
126
|
+
(seat.identity_cid && 'cid' in row && row.cid.toLowerCase() === seat.identity_cid.toLowerCase()))) {
|
|
127
|
+
throw new Error(`room member '${seat.role_name}' identity absence is not proven; refusing retirement`);
|
|
128
|
+
}
|
|
129
|
+
});
|
|
130
|
+
}
|
|
109
131
|
async function retireMember(roomId, seat, deps) {
|
|
110
132
|
let room = getRoomRecord(roomId);
|
|
111
133
|
let current = room.member_seats.find(candidate => candidate.role_name === seat.role_name);
|
|
112
134
|
let retirement = current.retirement;
|
|
135
|
+
if (retirement?.phase === 'identity_absent') {
|
|
136
|
+
if (current.launch?.launch_id && current.launch.launch_id !== retirement.launch_id) {
|
|
137
|
+
if (existsSync(agentDir(current.role_name, true)))
|
|
138
|
+
throw new Error(`room member '${current.role_name}' has a replacement launch after retirement; retire that exact temporary role before retrying`);
|
|
139
|
+
await assertMemberIdentityAbsent(current);
|
|
140
|
+
}
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
113
143
|
if (!retirement) {
|
|
144
|
+
if (current.launch?.state === 'failed' && !existsSync(agentDir(current.role_name, true))) {
|
|
145
|
+
// A failure before applyRole (for example invite-secret validation) has
|
|
146
|
+
// no supervisor to stop or archive. Settle only proven absence; this
|
|
147
|
+
// path never removes an identity based on missing local evidence.
|
|
148
|
+
await assertMemberIdentityAbsent(current);
|
|
149
|
+
advanceMemberRetirement(roomId, current.role_name, 'identity_absent', 'failed-launch-absent');
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
114
152
|
if (current.launch?.state === 'pending' && current.launch.attempt === 0) {
|
|
115
153
|
if (existsSync(agentDir(current.role_name, true))) {
|
|
116
154
|
throw new Error(`never-launched room member '${current.role_name}' unexpectedly has Fleet temp state`);
|
|
@@ -159,8 +197,6 @@ export async function closeManagedRoom(input) {
|
|
|
159
197
|
try {
|
|
160
198
|
if (room.close?.phase === 'retire_members') {
|
|
161
199
|
for (const seat of room.member_seats) {
|
|
162
|
-
if (seat.retirement?.phase === 'identity_absent')
|
|
163
|
-
continue;
|
|
164
200
|
await retireMember(input.roomId, seat, deps);
|
|
165
201
|
}
|
|
166
202
|
room = advanceRoomClose(input.roomId, 'close_cowork');
|
|
@@ -58,6 +58,10 @@ export interface CoworkAdapter {
|
|
|
58
58
|
briefing: string;
|
|
59
59
|
quiet_membership?: boolean;
|
|
60
60
|
anonymous?: boolean;
|
|
61
|
+
activation_requirements?: Array<{
|
|
62
|
+
role: string;
|
|
63
|
+
count: number;
|
|
64
|
+
}>;
|
|
61
65
|
}): Promise<CoworkRoomCreateResult>;
|
|
62
66
|
acceptInvite(roomId: string, invite: string, opts: {
|
|
63
67
|
role: string;
|
|
@@ -8,6 +8,8 @@ import { existsSync, readFileSync } from 'node:fs';
|
|
|
8
8
|
import { homedir } from 'node:os';
|
|
9
9
|
import { join, resolve } from 'node:path';
|
|
10
10
|
import { createConnection } from 'node:net';
|
|
11
|
+
import { readClientProfile } from '../client-profile.js';
|
|
12
|
+
import { coworkHttpCall } from './cowork-http.js';
|
|
11
13
|
const RPC_VERSION = 1;
|
|
12
14
|
const DEFAULT_TIMEOUT_MS = 30_000;
|
|
13
15
|
const MAX_RESPONSE_BYTES = 4 * 1024 * 1024;
|
|
@@ -275,10 +277,15 @@ function rpcCall(socketPath, method, params, timeoutMs, connect) {
|
|
|
275
277
|
});
|
|
276
278
|
}
|
|
277
279
|
export function createCoworkAdapter(options = {}) {
|
|
278
|
-
const
|
|
280
|
+
const env = options.env ?? process.env;
|
|
281
|
+
const explicitLocal = options.socketPath !== undefined || options.configPath !== undefined
|
|
282
|
+
|| env.OURS_COWORK_CONFIG !== undefined || !!env.OURS_COWORK_STATE_DIR;
|
|
283
|
+
const profile = explicitLocal ? undefined : readClientProfile({ ...env, HOME: options.home ?? env.HOME });
|
|
284
|
+
const remote = profile?.serverUrl ? profile : undefined;
|
|
285
|
+
const socketPath = remote ? undefined : resolveCoworkSocketPath(options);
|
|
279
286
|
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
280
287
|
const connect = options.connect ?? ((path) => createConnection(path));
|
|
281
|
-
const call = (method, params) => rpcCall(socketPath, method, params, timeoutMs, connect);
|
|
288
|
+
const call = (method, params) => remote ? coworkHttpCall(remote, method, params, timeoutMs) : rpcCall(socketPath, method, params, timeoutMs, connect);
|
|
282
289
|
return {
|
|
283
290
|
async available() {
|
|
284
291
|
try {
|
|
@@ -296,6 +303,7 @@ export function createCoworkAdapter(options = {}) {
|
|
|
296
303
|
briefing: opts.briefing,
|
|
297
304
|
...(opts.quiet_membership === undefined ? {} : { quiet_membership: opts.quiet_membership }),
|
|
298
305
|
...(opts.anonymous === undefined ? {} : { anonymous: opts.anonymous }),
|
|
306
|
+
...(opts.activation_requirements === undefined ? {} : { activation_requirements: opts.activation_requirements }),
|
|
299
307
|
}), 'room.create');
|
|
300
308
|
if (!result.identity_cid)
|
|
301
309
|
throw new CoworkProtocolError('room.create', 'created room did not establish an identity CID');
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
import type { ExplicitClientProfile } from '../client-profile.js';
|
|
2
|
+
/** Same RPC protocol as the local socket; never replay a possibly delivered mutation. */
|
|
3
|
+
export declare function coworkHttpCall(profile: ExplicitClientProfile, method: string, params: Record<string, unknown>, timeoutMs: number): Promise<unknown>;
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { attachOursClient } from '@ours.network/sdk/client';
|
|
3
|
+
import { readPrivateFile } from '@ours.network/sdk/connector';
|
|
4
|
+
import { CoworkProtocolError, CoworkUnavailableError } from './cowork-adapter.js';
|
|
5
|
+
/** Same RPC protocol as the local socket; never replay a possibly delivered mutation. */
|
|
6
|
+
export async function coworkHttpCall(profile, method, params, timeoutMs) {
|
|
7
|
+
const id = randomUUID();
|
|
8
|
+
const signal = AbortSignal.timeout(timeoutMs);
|
|
9
|
+
const body = JSON.stringify({ version: 1, id, method, params });
|
|
10
|
+
if (Buffer.byteLength(body) > 1024 * 1024)
|
|
11
|
+
throw new CoworkProtocolError(method, 'request exceeded 1 MiB');
|
|
12
|
+
try {
|
|
13
|
+
const client = await attachOursClient({ endpoint: profile.endpoint, expectedInstanceId: profile.expectedInstanceId,
|
|
14
|
+
credentialPath: profile.credentialPath, sessionMode: 'external', leaseToken: `fleet-cowork-check-${id}`, env: {}, requestSignal: signal });
|
|
15
|
+
await client.close();
|
|
16
|
+
const token = readPrivateFile(profile.credentialPath, 4096).toString('utf8').trim();
|
|
17
|
+
const response = await fetch(profile.serverUrl + '/cowork/management/rpc', {
|
|
18
|
+
method: 'POST', redirect: 'error', signal,
|
|
19
|
+
headers: { 'content-type': 'application/json', 'x-ours-api-token': token },
|
|
20
|
+
body,
|
|
21
|
+
});
|
|
22
|
+
if (response.status === 401 || response.status === 403) {
|
|
23
|
+
await response.body?.cancel();
|
|
24
|
+
throw new CoworkProtocolError(method, 'server credential was rejected', 'unauthorized');
|
|
25
|
+
}
|
|
26
|
+
const reader = response.body?.getReader();
|
|
27
|
+
if (!reader)
|
|
28
|
+
throw new CoworkProtocolError(method, 'empty HTTP response');
|
|
29
|
+
const chunks = [];
|
|
30
|
+
let size = 0;
|
|
31
|
+
try {
|
|
32
|
+
for (;;) {
|
|
33
|
+
const { done, value } = await reader.read();
|
|
34
|
+
if (done)
|
|
35
|
+
break;
|
|
36
|
+
size += value.length;
|
|
37
|
+
if (size > 4 * 1024 * 1024)
|
|
38
|
+
throw new CoworkProtocolError(method, 'response exceeded 4 MiB');
|
|
39
|
+
chunks.push(value);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
finally {
|
|
43
|
+
await reader.cancel();
|
|
44
|
+
}
|
|
45
|
+
let rpc;
|
|
46
|
+
try {
|
|
47
|
+
rpc = JSON.parse(Buffer.concat(chunks).toString('utf8'));
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
throw new CoworkProtocolError(method, 'server returned malformed JSON');
|
|
51
|
+
}
|
|
52
|
+
if (!rpc || Array.isArray(rpc) || rpc.version !== 1 || rpc.id !== id
|
|
53
|
+
|| Object.hasOwn(rpc, 'result') === Object.hasOwn(rpc, 'error'))
|
|
54
|
+
throw new CoworkProtocolError(method, 'server returned an invalid RPC response');
|
|
55
|
+
if (Object.hasOwn(rpc, 'error')) {
|
|
56
|
+
const error = rpc.error;
|
|
57
|
+
if (!error || typeof error.code !== 'string' || typeof error.message !== 'string')
|
|
58
|
+
throw new CoworkProtocolError(method, 'server returned an invalid RPC error');
|
|
59
|
+
throw new CoworkProtocolError(method, error.message, error.code);
|
|
60
|
+
}
|
|
61
|
+
if (!response.ok)
|
|
62
|
+
throw new CoworkProtocolError(method, `management answered HTTP ${response.status}`);
|
|
63
|
+
return rpc.result;
|
|
64
|
+
}
|
|
65
|
+
catch (error) {
|
|
66
|
+
if (error instanceof CoworkProtocolError)
|
|
67
|
+
throw error;
|
|
68
|
+
throw new CoworkUnavailableError('Cowork HTTP management failed; check the selected server and credential', { cause: error });
|
|
69
|
+
}
|
|
70
|
+
}
|
|
@@ -21,6 +21,17 @@ export interface StartupWaitPolicy {
|
|
|
21
21
|
sleep(ms: number): Promise<void>;
|
|
22
22
|
}
|
|
23
23
|
export declare function provisionMembers(input: ProvisionMembersInput): Promise<RoomOrchestrationRecord>;
|
|
24
|
+
/** Reconcile proven, admitted launches without any spawn, archive, invite or recovery path. */
|
|
25
|
+
export declare function reconcileExistingTaskMembers(input: {
|
|
26
|
+
taskId: string;
|
|
27
|
+
checkOnly?: boolean;
|
|
28
|
+
cowork: Pick<CoworkAdapter, 'getRoom'>;
|
|
29
|
+
expectedLaunches: ReadonlyArray<{
|
|
30
|
+
role_name: string;
|
|
31
|
+
launch_id: string;
|
|
32
|
+
identity_cid: string;
|
|
33
|
+
}>;
|
|
34
|
+
}): Promise<RoomOrchestrationRecord>;
|
|
24
35
|
export declare function cleanupMembers(input: {
|
|
25
36
|
roomId: string;
|
|
26
37
|
taskId?: string;
|
|
@@ -14,7 +14,7 @@ import { readLaunchSnapshot, redactLaunchDefinition } from './launch-snapshot.js
|
|
|
14
14
|
import { FLEET_PROXY_CALLER_ENV, FLEET_PROXY_STATE_DIR_ENV, } from '../fleet-proxy.js';
|
|
15
15
|
import { controlRequest } from '../session/control.js';
|
|
16
16
|
import { SessionControlError } from '../session/types.js';
|
|
17
|
-
import { closeManagedRoom } from './close.js';
|
|
17
|
+
import { closeManagedRoom, roomCloseLockPath } from './close.js';
|
|
18
18
|
import { withFileLock } from '../atomic-file.js';
|
|
19
19
|
import { TASK_OPERATION_LOCK_STALE_MS, taskOperationLockPath } from './terminal.js';
|
|
20
20
|
import { TaskStateError, taskDeletionState } from './task-state.js';
|
|
@@ -244,12 +244,16 @@ async function retainRunningLaunch(input) {
|
|
|
244
244
|
*/
|
|
245
245
|
async function launchMember(input) {
|
|
246
246
|
const taskId = input.provision.taskId;
|
|
247
|
-
|
|
247
|
+
const launch = () => withFileLock(roomCloseLockPath(input.provision.roomId), () => {
|
|
248
|
+
assertProvisioningOpen(input.provision);
|
|
248
249
|
return launchMemberUnlocked(input);
|
|
250
|
+
}, {}, TASK_OPERATION_LOCK_STALE_MS);
|
|
251
|
+
if (!taskId)
|
|
252
|
+
return launch();
|
|
249
253
|
return withFileLock(taskOperationLockPath(taskId), () => {
|
|
250
254
|
if (taskDeletionState(taskId) !== 'none')
|
|
251
255
|
throw new Error(`task ${taskId} is pending deletion; aborting member launch for ${input.member.name}`);
|
|
252
|
-
return
|
|
256
|
+
return launch();
|
|
253
257
|
}, {}, TASK_OPERATION_LOCK_STALE_MS);
|
|
254
258
|
}
|
|
255
259
|
async function launchMemberUnlocked(input) {
|
|
@@ -371,8 +375,16 @@ function assertCoworkRoomPolicy(room, expectedAnonymous) {
|
|
|
371
375
|
if ((room.anonymous ?? false) !== expectedAnonymous)
|
|
372
376
|
throw new Error(`Cowork anonymity (${String(room.anonymous ?? false)}) does not match Fleet's durable Room policy (${String(expectedAnonymous)})`);
|
|
373
377
|
}
|
|
378
|
+
function assertProvisioningOpen(input) {
|
|
379
|
+
if (input.taskId && getTask(input.taskId).terminal_intent)
|
|
380
|
+
throw new Error(`task ${input.taskId} has an accepted terminal intent; refusing to provision members`);
|
|
381
|
+
const room = getRoomRecord(input.roomId);
|
|
382
|
+
if (room?.state === 'closing' || room?.state === 'closed')
|
|
383
|
+
throw new Error(`room ${input.roomId} is ${room.state}; refusing to provision members`);
|
|
384
|
+
}
|
|
374
385
|
export async function provisionMembers(input) {
|
|
375
386
|
const { cfg, cowork, roomId, taskId, template } = input;
|
|
387
|
+
assertProvisioningOpen(input);
|
|
376
388
|
// Deletion-epoch pre-check; each member launch re-checks under the lock.
|
|
377
389
|
if (taskId && taskDeletionState(taskId) !== 'none')
|
|
378
390
|
throw new Error(`task ${taskId} is pending deletion; refusing to provision members`);
|
|
@@ -446,12 +458,25 @@ export async function provisionMembers(input) {
|
|
|
446
458
|
provision: input, member, settings: settings.get(member.name), task, roomIdentityCid,
|
|
447
459
|
}))
|
|
448
460
|
continue;
|
|
461
|
+
// A previous failed launch keeps its invite pointer durably. Never
|
|
462
|
+
// overwrite that requirement until the supported revoke has succeeded;
|
|
463
|
+
// a transport failure must fence subsequent invite issuance too.
|
|
464
|
+
if (currentSeat.invite_id) {
|
|
465
|
+
const observed = await cowork.getRoom(roomId);
|
|
466
|
+
if (!observed || observed.identity_cid !== roomIdentityCid)
|
|
467
|
+
throw new Error('cannot verify room before failed-attempt invite cleanup');
|
|
468
|
+
if (observed.seats.some(seat => seat.invite_id === currentSeat.invite_id
|
|
469
|
+
&& seat.seat_state !== 'removed'))
|
|
470
|
+
throw new Error('failed-attempt invite has an admitted seat; reconcile before replacing the member');
|
|
471
|
+
await cowork.revokeInvite(roomId, currentSeat.invite_id);
|
|
472
|
+
}
|
|
449
473
|
const issued = await cowork.issueInvite(roomId, {
|
|
450
474
|
mode: 'one_time', role: member.coworkRole, min_accepts: 1,
|
|
451
475
|
});
|
|
452
|
-
const seats = getRoomRecord(roomId).member_seats.map(seat => seat.role_name === member.name ? { ...seat, invite_id: issued.invite_id } : seat);
|
|
453
|
-
updateMemberSeats(roomId, seats);
|
|
454
476
|
try {
|
|
477
|
+
assertProvisioningOpen(input);
|
|
478
|
+
const seats = getRoomRecord(roomId).member_seats.map(seat => seat.role_name === member.name ? { ...seat, invite_id: issued.invite_id } : seat);
|
|
479
|
+
updateMemberSeats(roomId, seats);
|
|
455
480
|
await launchMember({
|
|
456
481
|
provision: input,
|
|
457
482
|
member,
|
|
@@ -470,7 +495,12 @@ export async function provisionMembers(input) {
|
|
|
470
495
|
});
|
|
471
496
|
}
|
|
472
497
|
catch (error) {
|
|
473
|
-
|
|
498
|
+
try {
|
|
499
|
+
await cowork.revokeInvite(roomId, issued.invite_id);
|
|
500
|
+
}
|
|
501
|
+
catch (cleanupError) {
|
|
502
|
+
throw new AggregateError([error, cleanupError], 'member launch failed and invite cleanup is unresolved; retry must revoke the retained requirement first');
|
|
503
|
+
}
|
|
474
504
|
throw error;
|
|
475
505
|
}
|
|
476
506
|
}
|
|
@@ -498,7 +528,7 @@ export async function provisionMembers(input) {
|
|
|
498
528
|
const remote = await cowork.recoverRoom(roomId);
|
|
499
529
|
assertCoworkRoomPolicy(remote, roomPolicy.anonymous);
|
|
500
530
|
const reconciled = reconcileMemberSeats(roomId, members, remote.seats, ownerSeatCid ?? undefined);
|
|
501
|
-
if (reconciled.complete)
|
|
531
|
+
if (reconciled.complete && remote.state === 'active')
|
|
502
532
|
break;
|
|
503
533
|
if (policy.now() >= deadline) {
|
|
504
534
|
advanceSaga(roomId, 'wait_seats', 5, 'waiting_seats');
|
|
@@ -538,6 +568,76 @@ export async function provisionMembers(input) {
|
|
|
538
568
|
activateTask(taskId);
|
|
539
569
|
return record;
|
|
540
570
|
}
|
|
571
|
+
/** Reconcile proven, admitted launches without any spawn, archive, invite or recovery path. */
|
|
572
|
+
export async function reconcileExistingTaskMembers(input) {
|
|
573
|
+
return withFileLock(taskOperationLockPath(input.taskId), async () => {
|
|
574
|
+
const task = getTask(input.taskId);
|
|
575
|
+
if (!['provisioning', 'active'].includes(task.state) || task.terminal_intent || taskDeletionState(input.taskId) !== 'none'
|
|
576
|
+
|| !task.room_id)
|
|
577
|
+
throw new Error('task is not open for existing-member reconciliation');
|
|
578
|
+
const roomId = task.room_id;
|
|
579
|
+
return withFileLock(roomCloseLockPath(roomId), async () => {
|
|
580
|
+
const current = getRoomRecord(roomId);
|
|
581
|
+
if (!current || !['provisioning', 'active'].includes(current.state) || current.close
|
|
582
|
+
|| current.task_id !== input.taskId || !current.room_identity_cid
|
|
583
|
+
|| task.room_identity_cid !== current.room_identity_cid)
|
|
584
|
+
throw new Error('room is not open for existing-member reconciliation');
|
|
585
|
+
const names = new Set(input.expectedLaunches.map(seat => seat.role_name));
|
|
586
|
+
if (!names.size || names.size !== input.expectedLaunches.length
|
|
587
|
+
|| current.member_seats.length !== names.size
|
|
588
|
+
|| current.member_seats.some(seat => !names.has(seat.role_name)))
|
|
589
|
+
throw new Error('expected launch roster does not match the persisted room');
|
|
590
|
+
const remote = await input.cowork.getRoom(roomId);
|
|
591
|
+
if (!remote || remote.room_id !== roomId || remote.identity_cid !== current.room_identity_cid
|
|
592
|
+
|| remote.state !== 'active')
|
|
593
|
+
throw new Error('Cowork room is not the exact active room');
|
|
594
|
+
assertCoworkRoomPolicy(remote, storedRoomLaunchPolicy(current.room_policy).anonymous);
|
|
595
|
+
if (current.owner_seat_cid && !remote.seats.some(seat => seat.identity_cid === current.owner_seat_cid && seat.seat_state === 'active'))
|
|
596
|
+
throw new Error('expected Owner seat is not active');
|
|
597
|
+
const seats = [];
|
|
598
|
+
for (const seat of current.member_seats) {
|
|
599
|
+
const expected = input.expectedLaunches.find(item => item.role_name === seat.role_name);
|
|
600
|
+
const launch = seat.launch;
|
|
601
|
+
if (seat.seat_state === 'removed' || !seat.invite_id || launch?.state !== 'launched'
|
|
602
|
+
|| launch.launch_id !== expected.launch_id || !launch.action_id || !launch.mission_sha256
|
|
603
|
+
|| (seat.identity_cid && seat.identity_cid !== expected.identity_cid))
|
|
604
|
+
throw new Error('persisted member launch does not match reconciliation evidence');
|
|
605
|
+
const dir = agentDir(seat.role_name, true);
|
|
606
|
+
if (!launchMatches(dir, { name: seat.role_name, coworkRole: seat.cowork_role }, launch.action_id, launch.mission_sha256, roomId, current.room_identity_cid, seat.invite_id, storedRoomLaunchPolicy(current.room_policy).anonymous))
|
|
607
|
+
throw new Error('member startup provenance mismatch');
|
|
608
|
+
const supervisor = readTempSupervisor(dir);
|
|
609
|
+
if (!supervisor || supervisor.role !== seat.role_name || supervisor.launchId !== expected.launch_id
|
|
610
|
+
|| await tempSupervisorLiveness(dir) !== 'running')
|
|
611
|
+
throw new Error('expected member supervisor is not running');
|
|
612
|
+
const ready = readRoomReadiness(current.room_identity_cid, seat.role_name);
|
|
613
|
+
const matches = remote.seats.filter(item => item.display_name === seat.role_name
|
|
614
|
+
&& item.seat_state !== 'removed');
|
|
615
|
+
const found = matches[0];
|
|
616
|
+
if (!ready || ready.room !== roomId || ready.invite !== seat.invite_id
|
|
617
|
+
|| ready.cid !== expected.identity_cid || matches.length !== 1 || !found
|
|
618
|
+
|| found.identity_cid !== expected.identity_cid || found.role !== seat.cowork_role
|
|
619
|
+
|| found.seat_state !== 'active' || found.invite_id !== seat.invite_id)
|
|
620
|
+
throw new Error('member readiness or authenticated Cowork seat mismatch');
|
|
621
|
+
seats.push({ ...seat, identity_cid: expected.identity_cid, seat_state: 'active' });
|
|
622
|
+
}
|
|
623
|
+
if (input.checkOnly)
|
|
624
|
+
return current;
|
|
625
|
+
// No mutation until the complete roster has passed; these writes are replayable
|
|
626
|
+
// under the same task/room lifecycle locks if interrupted between files.
|
|
627
|
+
updateMemberSeats(roomId, seats);
|
|
628
|
+
updateTaskMembers(input.taskId, seats.map(seat => ({ name: seat.role_name,
|
|
629
|
+
identity_cid: seat.identity_cid, slot: seat.slot, cowork_role: seat.cowork_role })));
|
|
630
|
+
if (getTask(input.taskId).blocked)
|
|
631
|
+
unblockTask(input.taskId);
|
|
632
|
+
if (current.state !== 'active')
|
|
633
|
+
advanceSaga(roomId, 'activate', 6);
|
|
634
|
+
const activated = current.state === 'active' ? getRoomRecord(roomId) : activateRoom(roomId);
|
|
635
|
+
if (task.state !== 'active')
|
|
636
|
+
activateTask(input.taskId);
|
|
637
|
+
return activated;
|
|
638
|
+
}, {}, TASK_OPERATION_LOCK_STALE_MS);
|
|
639
|
+
}, {}, TASK_OPERATION_LOCK_STALE_MS);
|
|
640
|
+
}
|
|
541
641
|
export async function cleanupMembers(input) {
|
|
542
642
|
const room = getRoomRecord(input.roomId);
|
|
543
643
|
if (!room)
|
|
@@ -112,6 +112,8 @@ export function setOwnerSeat(id, ownerSeatCid, inviteFingerprint) {
|
|
|
112
112
|
}
|
|
113
113
|
export function updateMemberSeats(id, seats) {
|
|
114
114
|
const r = readRoom(id);
|
|
115
|
+
if (r.state === 'closing' || r.state === 'closed')
|
|
116
|
+
throw new RoomStateError(`room ${id} is ${r.state}; member provisioning is fenced`);
|
|
115
117
|
r.member_seats = seats;
|
|
116
118
|
writeRoom(r);
|
|
117
119
|
return r;
|
|
@@ -135,6 +137,8 @@ const LAUNCH_ORDER = [
|
|
|
135
137
|
];
|
|
136
138
|
export function updateMemberStartup(id, roleName, update) {
|
|
137
139
|
const r = readRoom(id);
|
|
140
|
+
if (r.state === 'closing' || r.state === 'closed')
|
|
141
|
+
throw new RoomStateError(`room ${id} is ${r.state}; member provisioning is fenced`);
|
|
138
142
|
const seat = r.member_seats.find(candidate => candidate.role_name === roleName);
|
|
139
143
|
if (!seat)
|
|
140
144
|
throw new RoomStateError(`room ${id} has no recorded member ${roleName}`);
|
|
@@ -151,6 +155,8 @@ export function updateMemberStartup(id, roleName, update) {
|
|
|
151
155
|
}
|
|
152
156
|
export function activateRoom(id) {
|
|
153
157
|
const r = readRoom(id);
|
|
158
|
+
if (r.state === 'closing' || r.state === 'closed')
|
|
159
|
+
throw new RoomStateError(`room ${id} is ${r.state}; activation is fenced`);
|
|
154
160
|
r.state = 'active';
|
|
155
161
|
r.saga = { phase: 'completed', step_index: 0 };
|
|
156
162
|
delete r.provisioning_detail;
|
package/dist/runner.js
CHANGED
|
@@ -662,6 +662,7 @@ export async function runOnce(name, opts = {}, partialDeps = {}) {
|
|
|
662
662
|
harness: role.harness,
|
|
663
663
|
config: role.owner_channel,
|
|
664
664
|
session: arbiter,
|
|
665
|
+
startupPending: () => !sessionStartupComplete,
|
|
665
666
|
stateDir: dir,
|
|
666
667
|
env: role.env,
|
|
667
668
|
log: deps.log,
|
|
@@ -756,6 +757,26 @@ export async function runOnce(name, opts = {}, partialDeps = {}) {
|
|
|
756
757
|
// interruption to steering until this startup turn reaches a terminal
|
|
757
758
|
// success, so there is neither a deaf gap nor a boot-cancellation loop.
|
|
758
759
|
monitorLoop = monitor?.run(pid);
|
|
760
|
+
// Owner traffic and agent replies must be observed during a long first
|
|
761
|
+
// turn. Ordinary owner input steers until startup has proved successful.
|
|
762
|
+
if (ownerChannel) {
|
|
763
|
+
try {
|
|
764
|
+
await ownerChannel.start();
|
|
765
|
+
control.setOwnerChannel(ownerChannel);
|
|
766
|
+
}
|
|
767
|
+
catch (error) {
|
|
768
|
+
monitor?.stop();
|
|
769
|
+
if (monitorLoop)
|
|
770
|
+
await monitorLoop;
|
|
771
|
+
await control.close();
|
|
772
|
+
await ownerChannel.close().catch(() => undefined);
|
|
773
|
+
ownerBinder?.release();
|
|
774
|
+
await agentSession.close();
|
|
775
|
+
unsubscribeRecovery?.();
|
|
776
|
+
throw new Error(`[${name}] owner channel failed to start: `
|
|
777
|
+
+ `${error?.message ?? String(error)}`);
|
|
778
|
+
}
|
|
779
|
+
}
|
|
759
780
|
const started = await starting;
|
|
760
781
|
// A temporary role's first turn can be the active turn when an ours wake
|
|
761
782
|
// needs immediate attention. A typed console/monitor cancellation ends
|
|
@@ -768,6 +789,7 @@ export async function runOnce(name, opts = {}, partialDeps = {}) {
|
|
|
768
789
|
if (!started.succeeded && !interruptedForWake) {
|
|
769
790
|
monitor?.stop();
|
|
770
791
|
await control.close();
|
|
792
|
+
await ownerChannel?.close().catch(() => undefined);
|
|
771
793
|
ownerBinder?.release();
|
|
772
794
|
await agentSession.close();
|
|
773
795
|
unsubscribeRecovery?.();
|
|
@@ -795,26 +817,6 @@ export async function runOnce(name, opts = {}, partialDeps = {}) {
|
|
|
795
817
|
deps.log(`[${name}] ${sessionLabel} startup prompt cancelled by ${started.cancellationSource}; `
|
|
796
818
|
+ 'keeping temporary supervisor alive');
|
|
797
819
|
sessionStartupComplete = true;
|
|
798
|
-
if (ownerChannel) {
|
|
799
|
-
try {
|
|
800
|
-
await ownerChannel.start();
|
|
801
|
-
}
|
|
802
|
-
catch (error) {
|
|
803
|
-
monitor?.stop();
|
|
804
|
-
if (monitorLoop)
|
|
805
|
-
await monitorLoop;
|
|
806
|
-
await ownerChannel.close().catch(() => undefined);
|
|
807
|
-
await control.close();
|
|
808
|
-
ownerBinder?.release();
|
|
809
|
-
await agentSession.close();
|
|
810
|
-
unsubscribeRecovery?.();
|
|
811
|
-
throw new Error(`[${name}] owner channel failed to start: `
|
|
812
|
-
+ `${error?.message ?? String(error)}`);
|
|
813
|
-
}
|
|
814
|
-
}
|
|
815
|
-
if (ownerChannel) {
|
|
816
|
-
control.setOwnerChannel(ownerChannel);
|
|
817
|
-
}
|
|
818
820
|
reloadLoopConfig = async () => {
|
|
819
821
|
const nextRole = findRole(loadConfig(configPath), name);
|
|
820
822
|
const definitions = nextRole.loops ?? [];
|
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.6",
|
|
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",
|
|
@@ -41,9 +41,9 @@
|
|
|
41
41
|
"@agentclientprotocol/sdk": "^1.3.0",
|
|
42
42
|
"@fastify/static": "^10.1.2",
|
|
43
43
|
"@fastify/websocket": "^11.2.0",
|
|
44
|
-
"@ours.network/cli": "2.8.1-nightly.
|
|
45
|
-
"@ours.network/mcp": "1.2.0-nightly.
|
|
46
|
-
"@ours.network/sdk": "3.8.1-nightly.
|
|
44
|
+
"@ours.network/cli": "2.8.1-nightly.9",
|
|
45
|
+
"@ours.network/mcp": "1.2.0-nightly.7",
|
|
46
|
+
"@ours.network/sdk": "3.8.1-nightly.11",
|
|
47
47
|
"commander": "^12.1.0",
|
|
48
48
|
"fastify": "^5.4.0",
|
|
49
49
|
"react": "^19.1.1",
|
|
@@ -57,7 +57,7 @@
|
|
|
57
57
|
"@agentclientprotocol/codex-acp": "1.10.0"
|
|
58
58
|
},
|
|
59
59
|
"devDependencies": {
|
|
60
|
-
"@ours.network/daemon": "3.8.1-nightly.
|
|
60
|
+
"@ours.network/daemon": "3.8.1-nightly.3",
|
|
61
61
|
"@playwright/test": "^1.54.1",
|
|
62
62
|
"@types/node": "^20.14.0",
|
|
63
63
|
"@types/react": "^19.1.9",
|