@ours.network/fleet 1.2.0-nightly.4 → 1.2.0-nightly.5

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 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.
@@ -1,10 +1,11 @@
1
1
  {
2
- "version": "1.2.0-nightly.4",
3
- "buildId": "10dccae7a337",
4
- "commit": "2192785d3823d7a2cae1f3b5c7060bd97175340e",
2
+ "version": "1.2.0-nightly.5",
3
+ "buildId": "4d7255bd137c",
4
+ "commit": "273d3bec2505bd1a4244c6b00b836238faaac99e",
5
5
  "dirty": true,
6
- "builtAt": "2026-09-21T20:49:10.393Z",
6
+ "builtAt": "2026-09-22T18:42:44.399Z",
7
7
  "capabilities": [
8
+ "cowork.http-management-v1",
8
9
  "monitor.interrupt.after_tool"
9
10
  ]
10
11
  }
@@ -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 CAPABILITIES: readonly ["monitor.interrupt.after_tool"];
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;
@@ -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`? */
@@ -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);
@@ -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 || url.pathname !== '/'
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 origin');
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
- endpoint: url.origin, expectedInstanceId, credentialPath, configPath,
124
+ ...(serverUrl === undefined ? {} : { serverUrl }),
125
+ endpoint: normalizeDaemonEndpoint(endpoint), expectedInstanceId, credentialPath, configPath,
111
126
  });
112
127
  }
113
128
  export function clientProfileKey(profile) {
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 { userInfo } from 'node:os';
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
- const client = await attachDaemon({
277
- env: process.env,
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
- clientPid: process.pid,
288
+ } : {
289
+ env: process.env, leaseToken: `ours-fleet-doctor-${process.pid}`, clientPid: process.pid,
280
290
  });
281
- const info = await client.version();
282
- if (info.name !== 'ours' || typeof info.version !== 'string')
283
- throw new Error('the selected endpoint did not return a valid ours daemon identity');
284
- checks.push({ name: 'ours daemon', ok: true, detail: `running (${info.version})` });
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: `not reachable through the SDK — start it with: ours-daemon start `
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
  }
@@ -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 socketPath = resolveCoworkSocketPath(options);
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 {
@@ -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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ours.network/fleet",
3
- "version": "1.2.0-nightly.4",
3
+ "version": "1.2.0-nightly.5",
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",