@ours.network/codex 0.17.0-nightly.9 → 0.18.0-nightly.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.codex-plugin/plugin.json +1 -1
- package/bin/proxy.mjs +2 -2
- package/dist/monitor-mcp.mjs +2 -2
- package/install.sh +10 -10
- package/package.json +3 -2
- package/skills/ours/SKILL.md +8 -16
- package/skills/ours/references/configuration.md +26 -76
- package/src/hooks/runner.mjs +22 -10
- package/src/monitor-mcp.mjs +2 -2
- package/src/profile.mjs +35 -186
package/bin/proxy.mjs
CHANGED
|
@@ -24,7 +24,7 @@ const env = { ...process.env };
|
|
|
24
24
|
if (env.OURS_CODEX_LIVE === '1') env.OURS_AUTOSTART = '0';
|
|
25
25
|
if (process.ppid > 1) env.OURS_CLIENT_PID = String(process.ppid);
|
|
26
26
|
const child = cliPath
|
|
27
|
-
? spawn(process.execPath, [cliPath, 'proxy',
|
|
28
|
-
: spawn('ours-mcp', ['proxy',
|
|
27
|
+
? spawn(process.execPath, [cliPath, 'proxy', ...process.argv.slice(2)], { stdio: 'inherit', env })
|
|
28
|
+
: spawn('ours-mcp', ['proxy', ...process.argv.slice(2)], { stdio: 'inherit', env });
|
|
29
29
|
child.on('error', (error) => { process.stderr.write(`ours: cannot launch @ours.network/mcp proxy: ${error.message}\n`); process.exit(1); });
|
|
30
30
|
child.on('exit', (code, signal) => { if (signal) process.kill(process.pid, signal); else process.exit(code ?? 0); });
|
package/dist/monitor-mcp.mjs
CHANGED
|
@@ -21156,9 +21156,9 @@ var foregroundOffer = (identity) => `IMPORTANT: Before offering the fallback, te
|
|
|
21156
21156
|
function foregroundWatchProcess(identity) {
|
|
21157
21157
|
try {
|
|
21158
21158
|
const cliPath = require2.resolve("@ours.network/mcp/dist/cli.js");
|
|
21159
|
-
return { command: process.execPath, args: [cliPath, "watch",
|
|
21159
|
+
return { command: process.execPath, args: [cliPath, "watch", identity] };
|
|
21160
21160
|
} catch {
|
|
21161
|
-
return { command: "ours-mcp", args: ["watch",
|
|
21161
|
+
return { command: "ours-mcp", args: ["watch", identity] };
|
|
21162
21162
|
}
|
|
21163
21163
|
}
|
|
21164
21164
|
async function handleMonitorCommand(command, args = {}, { env = process.env, send = sendControlCommand } = {}) {
|
package/install.sh
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env bash
|
|
2
2
|
# Install the native ours.network plugin into the OpenAI Codex CLI:
|
|
3
|
-
# 1. ensure the ours
|
|
3
|
+
# 1. ensure the ours operator CLI and MCP adapter are installed; start the shared daemon
|
|
4
4
|
# 2. add/upgrade adapt-toolkit/ours-codex-marketplace
|
|
5
5
|
# 3. install the native `ours` plugin (skills, MCP servers, and hooks)
|
|
6
6
|
# 4. back up and remove installer-owned legacy config only after verification
|
|
@@ -27,18 +27,18 @@ say(){ printf 'ours-install: %s\n' "$1"; }
|
|
|
27
27
|
ensure_daemon_latest(){
|
|
28
28
|
if [ "${OURS_INSTALL_SKIP_DAEMON:-}" = "1" ]; then say "skipping daemon step (OURS_INSTALL_SKIP_DAEMON=1)"; return 0; fi
|
|
29
29
|
local before after
|
|
30
|
-
before="$(ours
|
|
31
|
-
say "ensuring @ours.network/mcp@latest…"
|
|
32
|
-
npm i -g @ours.network/mcp@latest
|
|
33
|
-
after="$(ours
|
|
34
|
-
if ! ours
|
|
35
|
-
say "starting the ours daemon…"; ours
|
|
30
|
+
before="$(ours version 2>/dev/null | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -1 || true)"
|
|
31
|
+
say "ensuring @ours.network/cli@latest and @ours.network/mcp@latest…"
|
|
32
|
+
npm i -g @ours.network/cli@latest @ours.network/mcp@latest
|
|
33
|
+
after="$(ours version 2>/dev/null | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -1 || true)"
|
|
34
|
+
if ! ours daemon status >/dev/null 2>&1; then
|
|
35
|
+
say "starting the ours daemon…"; ours daemon start || say "could not start; run 'ours daemon start' if the tools error."
|
|
36
36
|
elif [ -n "$before" ] && [ "$before" != "$after" ]; then
|
|
37
|
-
say "
|
|
37
|
+
say "operator CLI upgraded (v${before} → v${after}) — restarting its daemon…"; ours daemon restart || ours daemon start || true
|
|
38
38
|
else
|
|
39
39
|
say "daemon already current (v${after:-unknown})."
|
|
40
40
|
fi
|
|
41
|
-
say "
|
|
41
|
+
say "operator CLI: $(command -v ours) (v${after:-unknown}); MCP adapter: $(command -v ours-mcp)"
|
|
42
42
|
}
|
|
43
43
|
|
|
44
44
|
# --- 1) daemon (ensure @latest + restart on change) ---
|
|
@@ -91,7 +91,7 @@ say "done. The ours MCP server + skill are live for the next Codex session."
|
|
|
91
91
|
# --- version echo: show the user they are on latest ---
|
|
92
92
|
if [ "${OURS_INSTALL_SKIP_DAEMON:-}" != "1" ]; then
|
|
93
93
|
say "versions:"
|
|
94
|
-
say "
|
|
94
|
+
say " MCP adapter: $(ours-mcp --version 2>/dev/null | head -1 || echo 'unknown')"
|
|
95
95
|
say " plugin: $(npm ls -g @ours.network/codex 2>/dev/null | grep -oE '@ours\.network/codex@[0-9][0-9.]*' | head -1 || echo '@ours.network/codex (not a global install)')"
|
|
96
96
|
fi
|
|
97
97
|
say "next: bind (or create) an identity, then the ours skill tails ours-mcp watch (or polls"
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ours.network/codex",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.18.0-nightly.1",
|
|
4
4
|
"description": "Native Codex plugin for secure ours.network messaging and explicitly armed, session-scoped live mail wake.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "FSL-1.1-Apache-2.0",
|
|
@@ -46,7 +46,8 @@
|
|
|
46
46
|
},
|
|
47
47
|
"dependencies": {
|
|
48
48
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
49
|
-
"@ours.network/mcp": "0.
|
|
49
|
+
"@ours.network/mcp": "0.18.0-nightly.1",
|
|
50
|
+
"@ours.network/sdk": "2.0.1",
|
|
50
51
|
"ws": "^8.21.0",
|
|
51
52
|
"zod": "^3.25.76"
|
|
52
53
|
},
|
package/skills/ours/SKILL.md
CHANGED
|
@@ -79,19 +79,12 @@ allows it for legacy reasons; this skill does not.
|
|
|
79
79
|
|
|
80
80
|
Walk the user through these, checking each. Stop and help at the first one that isn't done.
|
|
81
81
|
|
|
82
|
-
1. **Daemon running.** The MCP tools
|
|
83
|
-
`ours
|
|
84
|
-
@ours.network/mcp`, then `ours
|
|
85
|
-
`ours
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
type `! ours-mcp status` etc.
|
|
89
|
-
Then check optional voice support with `ours-mcp voice-status --json`. If it is
|
|
90
|
-
not ready and the user wants voice transcription, ask them to run `ours-install`
|
|
91
|
-
in a terminal: it re-detects incomplete setup and reads the provider key with
|
|
92
|
-
hidden input. **Never ask for, paste, echo, or put the key in chat/tool arguments.**
|
|
93
|
-
Environment-only operators may set `OURS_STT_*` themselves. Troubleshooting and
|
|
94
|
-
the exact Telegram OGG/Opus fallback contract are in `references/configuration.md`.
|
|
82
|
+
1. **Daemon running.** The MCP tools attach to the shared daemon. Check it with
|
|
83
|
+
`ours daemon status`. If the commands are missing, install
|
|
84
|
+
`@ours.network/cli@1.0.1` and `@ours.network/mcp`, then run `ours config setup`
|
|
85
|
+
and `ours daemon start`. For boot persistence offer
|
|
86
|
+
`ours daemon install-service`. These are operator commands; explain the shared
|
|
87
|
+
blast radius and obtain consent before changing configuration or lifecycle.
|
|
95
88
|
2. **Plugin installed.** Install the native plugin from the ours Codex marketplace, or
|
|
96
89
|
install `@ours.network/codex` globally and run `ours-codex-install`. Start a new
|
|
97
90
|
Codex thread after installation. The native package bundles skills, the ours and
|
|
@@ -237,9 +230,8 @@ random public-safe `tmp-…` name), binds it to this session, and marks it **tem
|
|
|
237
230
|
|
|
238
231
|
If a notice says your plugin and the running daemon are different
|
|
239
232
|
versions, it is **advisory** — everything still works. Relay it to the user and,
|
|
240
|
-
if they want matching versions, tell them
|
|
241
|
-
|
|
242
|
-
mid-task (the next session starts the new version), or update the lagging side.
|
|
233
|
+
if they want matching versions, tell them to update the lagging package and run
|
|
234
|
+
`ours daemon restart` only when no other session is mid-task.
|
|
243
235
|
Do **not** stop work, refuse, or restart anything on your own over this.
|
|
244
236
|
|
|
245
237
|
### Workspace identity pin (`.ours-identity`)
|
|
@@ -1,76 +1,26 @@
|
|
|
1
|
-
# ours
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
explicit consent before editing or restarting anything. A changed state directory selects a
|
|
28
|
-
different identity store. Use a distinct `OURS_CONFIG`, port, and state directory for a
|
|
29
|
-
second daemon.
|
|
30
|
-
|
|
31
|
-
Standard mode and live mode use the same MCP tools. Live mode only adds explicitly armed,
|
|
32
|
-
session-scoped wake; it stops with the `ours-codex` session.
|
|
33
|
-
|
|
34
|
-
## Voice-message transcription
|
|
35
|
-
|
|
36
|
-
Run `ours-mcp voice-status --json` first. It reports only readiness, provider,
|
|
37
|
-
key presence/source, and a missing-field reason; it never returns the key. A
|
|
38
|
-
ready result is idempotent: keep it and do not ask for setup again. A not-ready
|
|
39
|
-
result should be offered again on every interactive `ours-install` rerun.
|
|
40
|
-
Headless/`OURS_ASSUME_YES` runs never prompt and never invent credentials.
|
|
41
|
-
|
|
42
|
-
Safest guided setup: ask the user to run `ours-install` in their own terminal.
|
|
43
|
-
Its API-key prompt is hidden, it writes `config.json` atomically with mode
|
|
44
|
-
`0600`, and it restores the prior file if the daemon cannot reload the change.
|
|
45
|
-
Never request a provider key in chat, pass one through an agent tool/command
|
|
46
|
-
argument, print the `stt` config block, or test with a real key. Environment-only
|
|
47
|
-
operators can set `OURS_STT_PROVIDER`, `OURS_STT_API_KEY`, `OURS_STT_MODEL`,
|
|
48
|
-
`OURS_STT_BASE_URL`, and `OURS_STT_LANGUAGE`; environment values override the
|
|
49
|
-
file field-by-field.
|
|
50
|
-
|
|
51
|
-
Provider requirements:
|
|
52
|
-
|
|
53
|
-
- `openai-compatible`: key + explicit `/v1` base URL + model.
|
|
54
|
-
- `elevenlabs`: key + model; base URL is optional.
|
|
55
|
-
- `deepgram`: key; model/base URL are optional provider defaults.
|
|
56
|
-
- `custom`: key + `stt.custom.url`; model is required when the custom template
|
|
57
|
-
references it.
|
|
58
|
-
|
|
59
|
-
Troubleshooting:
|
|
60
|
-
|
|
61
|
-
- “not ready” names the missing field. Do not ask the user to reveal its value.
|
|
62
|
-
- If a file edit appears ineffective, check the reported key source and
|
|
63
|
-
`OURS_STT_*`; an environment override may shadow the file.
|
|
64
|
-
- Config changes require a daemon restart and active sessions may need to bind
|
|
65
|
-
their identity again.
|
|
66
|
-
- Incoming voice is recognized strictly as an `audio/*` MIME carrying
|
|
67
|
-
`x-ours-kind=voice-message`, or the legacy `voice-message-…` audio filename.
|
|
68
|
-
Generic audio and connector-specific filename guesses remain ordinary files.
|
|
69
|
-
- Telegram fallback preserves the original OGG/Opus bytes and `.ogg` filename.
|
|
70
|
-
Its `send_file` MIME and correlated v2 envelope `attachment.mime` must both be
|
|
71
|
-
`audio/ogg; x-ours-kind=voice-message`; `attachment.wire_id` identifies the
|
|
72
|
-
separately delivered file. Connector-local STT success may remain text-only.
|
|
73
|
-
- Oversized audio is saved but not uploaded (daemon default: 5 MiB). Provider
|
|
74
|
-
HTTP, timeout, malformed-response, and network failures degrade to a precise
|
|
75
|
-
“transcription failed” line with the saved audio path; provider responses are
|
|
76
|
-
scrubbed if they echo the configured key.
|
|
1
|
+
# ours daemon configuration
|
|
2
|
+
|
|
3
|
+
ours-mcp is a client of one already-running shared daemon. The operator CLI owns
|
|
4
|
+
configuration and lifecycle:
|
|
5
|
+
|
|
6
|
+
```sh
|
|
7
|
+
ours config show --json
|
|
8
|
+
ours config setup --port 3050 --state-dir "$HOME/.ours"
|
|
9
|
+
ours daemon start
|
|
10
|
+
ours daemon status --json
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
The MCP adapter and `ours-codex` use the published SDK's coherent selection. The
|
|
14
|
+
wholly default selection is port 3050 with state directory `~/.ours`. For another
|
|
15
|
+
daemon, set `OURS_CONFIG`, or set matching `OURS_PORT` and `OURS_STATE_DIR`. A
|
|
16
|
+
token or endpoint selection must be paired with its state directory. `/state-dir`
|
|
17
|
+
is verified before credentials are sent.
|
|
18
|
+
|
|
19
|
+
The adapter never starts a daemon and never falls back to an embedded one.
|
|
20
|
+
`OURS_INSTANCE`, `--application`, and old ours-mcp daemon variables are errors.
|
|
21
|
+
Standard and live Codex mode use the same selection; live mode only adds an
|
|
22
|
+
explicitly armed, session-scoped wake monitor.
|
|
23
|
+
|
|
24
|
+
Changing daemon configuration or restarting the shared daemon affects every
|
|
25
|
+
connected application. Explain that blast radius and obtain the user's consent
|
|
26
|
+
before making operator-level changes.
|
package/src/hooks/runner.mjs
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { readFile } from 'node:fs/promises';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
3
4
|
import { dirname, join, resolve } from 'node:path';
|
|
4
5
|
import { pathToFileURL } from 'node:url';
|
|
5
6
|
import { sendControlCommand } from '../control-server.mjs';
|
|
@@ -30,6 +31,14 @@ const safeUnread = (value) => (Array.isArray(value?.identities) ? value.identiti
|
|
|
30
31
|
return [{ name: entry.name, count, files, recent }];
|
|
31
32
|
});
|
|
32
33
|
|
|
34
|
+
async function applicationIdentityNames(env, stateDir) {
|
|
35
|
+
const path = env.OURS_MCP_CONFIG || join(homedir(), '.ours-mcp', 'config.json');
|
|
36
|
+
const value = JSON.parse(await readFile(path, 'utf8'));
|
|
37
|
+
if (value?.version !== 1 || !value.daemons || typeof value.daemons !== 'object') return new Set();
|
|
38
|
+
const identities = value.daemons[resolve(stateDir)]?.identities;
|
|
39
|
+
return new Set(Array.isArray(identities) ? identities.filter((name) => typeof name === 'string') : []);
|
|
40
|
+
}
|
|
41
|
+
|
|
33
42
|
function renderContext(unread, pin) {
|
|
34
43
|
const lines = [];
|
|
35
44
|
if (unread.length) {
|
|
@@ -64,22 +73,25 @@ export async function handleHook(payload, { env = process.env, fetch: fetchImpl
|
|
|
64
73
|
await send(socket, capability, { command: 'register_session', sessionId: payload.session_id, threadId: payload.session_id, cwd: payload.cwd });
|
|
65
74
|
}
|
|
66
75
|
let port = env.OURS_PORT || '3050';
|
|
76
|
+
let selectedStateDir = resolve(env.OURS_STATE_DIR || join(homedir(), '.ours'));
|
|
67
77
|
let selectedToken = env.OURS_API_TOKEN || '';
|
|
68
78
|
// Standard Codex does not pass through ours-codex's resolved environment.
|
|
69
|
-
// Resolve
|
|
70
|
-
// inspect the same daemon as the stdio proxy.
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
}
|
|
79
|
+
// Resolve the same coherent SDK selection here so SessionStart and
|
|
80
|
+
// UserPromptSubmit inspect the same daemon as the stdio proxy.
|
|
81
|
+
try {
|
|
82
|
+
const selected = await resolveDaemonProfile({ env, fetch: fetchImpl });
|
|
83
|
+
port = String(selected.port);
|
|
84
|
+
selectedToken = selected.token || '';
|
|
85
|
+
selectedStateDir = resolve(selected.stateDir);
|
|
86
|
+
} catch { /* proxy/launcher owns diagnostics; hooks emit a benign no-op */ }
|
|
78
87
|
const headers = selectedToken ? { 'x-ours-api-token': selectedToken } : {};
|
|
79
88
|
let unread = [];
|
|
80
89
|
try {
|
|
81
90
|
const response = await fetchImpl(`http://127.0.0.1:${port}/unread`, { headers, signal: AbortSignal.timeout(1500) });
|
|
82
|
-
if (response.ok)
|
|
91
|
+
if (response.ok) {
|
|
92
|
+
const visible = await applicationIdentityNames(env, selectedStateDir);
|
|
93
|
+
unread = safeUnread(await response.json()).filter((entry) => visible.has(entry.name));
|
|
94
|
+
}
|
|
83
95
|
} catch { /* daemon diagnostics belong to launcher/proxy */ }
|
|
84
96
|
const pin = await findPin(payload.cwd || process.cwd());
|
|
85
97
|
const context = renderContext(unread, pin);
|
package/src/monitor-mcp.mjs
CHANGED
|
@@ -23,9 +23,9 @@ const foregroundOffer = (identity) =>
|
|
|
23
23
|
export function foregroundWatchProcess(identity) {
|
|
24
24
|
try {
|
|
25
25
|
const cliPath = require.resolve('@ours.network/mcp/dist/cli.js');
|
|
26
|
-
return { command: process.execPath, args: [cliPath, 'watch',
|
|
26
|
+
return { command: process.execPath, args: [cliPath, 'watch', identity] };
|
|
27
27
|
} catch {
|
|
28
|
-
return { command: 'ours-mcp', args: ['watch',
|
|
28
|
+
return { command: 'ours-mcp', args: ['watch', identity] };
|
|
29
29
|
}
|
|
30
30
|
}
|
|
31
31
|
|
package/src/profile.mjs
CHANGED
|
@@ -1,8 +1,7 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { join } from 'node:path';
|
|
2
2
|
import { homedir } from 'node:os';
|
|
3
|
-
import { isAbsolute, join, normalize, resolve } from 'node:path';
|
|
4
3
|
|
|
5
|
-
|
|
4
|
+
import { assertDaemonStateDir, resolveDaemonConfig } from '@ours.network/sdk';
|
|
6
5
|
|
|
7
6
|
function validPort(value) {
|
|
8
7
|
const port = Number(value);
|
|
@@ -13,11 +12,11 @@ function validPort(value) {
|
|
|
13
12
|
export function parseOursArgs(argv = []) {
|
|
14
13
|
const codexArgs = [];
|
|
15
14
|
let port;
|
|
16
|
-
for (let
|
|
17
|
-
const arg = argv[
|
|
15
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
16
|
+
const arg = argv[index];
|
|
18
17
|
if (arg === '--ours-port') {
|
|
19
|
-
if (argv[
|
|
20
|
-
port = validPort(argv[++
|
|
18
|
+
if (argv[index + 1] == null) throw new Error('ours: --ours-port requires a value');
|
|
19
|
+
port = validPort(argv[++index]);
|
|
21
20
|
} else if (arg.startsWith('--ours-port=')) {
|
|
22
21
|
port = validPort(arg.slice('--ours-port='.length));
|
|
23
22
|
} else {
|
|
@@ -27,191 +26,41 @@ export function parseOursArgs(argv = []) {
|
|
|
27
26
|
return { port, codexArgs };
|
|
28
27
|
}
|
|
29
28
|
|
|
30
|
-
async function
|
|
31
|
-
try { return JSON.parse(await readFile(path, 'utf8')); } catch { return {}; }
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
async function readRequiredJson(path, kind) {
|
|
35
|
-
let text;
|
|
36
|
-
try { text = await readFile(path, 'utf8'); }
|
|
37
|
-
catch (error) { throw rerun(`cannot read ${kind} ${path}: ${error.message}`); }
|
|
38
|
-
try {
|
|
39
|
-
const value = JSON.parse(text);
|
|
40
|
-
if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('expected an object');
|
|
41
|
-
return value;
|
|
42
|
-
} catch (error) { throw rerun(`${kind} ${path} is corrupt JSON: ${error.message}`); }
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
async function readRegistryJson(path) {
|
|
46
|
-
let text;
|
|
47
|
-
try { text = await readFile(path, 'utf8'); }
|
|
48
|
-
catch (error) {
|
|
49
|
-
if (error?.code === 'ENOENT') return null;
|
|
50
|
-
throw error;
|
|
51
|
-
}
|
|
52
|
-
try { return JSON.parse(text); }
|
|
53
|
-
catch (error) { throw rerun(`registry ${path} is corrupt JSON: ${error.message}`); }
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
async function readOwnerToken(stateDir) {
|
|
57
|
-
try { return (await readFile(join(stateDir, 'daemon-token'), 'utf8')).trim() || null; } catch { return null; }
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
export class AssociationError extends Error {
|
|
61
|
-
constructor(message, code = 'INVALID_ASSOCIATION') {
|
|
62
|
-
super(`ours profile association: ${message}. Re-run the Nightly installer to repair this association`);
|
|
63
|
-
this.name = 'AssociationError';
|
|
64
|
-
this.code = code;
|
|
65
|
-
}
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
const rerun = (message, code) => new AssociationError(message, code);
|
|
69
|
-
const PROFILE_ID = /^[A-Za-z0-9](?:[A-Za-z0-9_-]{0,30}[A-Za-z0-9])?$/;
|
|
70
|
-
const APPLICATIONS = ['claude-code', 'codex', 'hermes'];
|
|
71
|
-
|
|
72
|
-
function associatedPath(value, profileId, field) {
|
|
73
|
-
if (typeof value !== 'string' || !isAbsolute(value) || normalize(value) !== value || value === '/') {
|
|
74
|
-
throw rerun(`profile ${profileId} has unsafe ${field}`);
|
|
75
|
-
}
|
|
76
|
-
return value;
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
function validateProfile(profileId, value) {
|
|
80
|
-
if (!PROFILE_ID.test(profileId)) throw rerun(`invalid profile id ${JSON.stringify(profileId)}`);
|
|
81
|
-
if (!value || typeof value !== 'object' || Array.isArray(value)) throw rerun(`profile ${profileId} is not an object`);
|
|
82
|
-
for (const key of Object.keys(value)) {
|
|
83
|
-
if (!['label', 'host', 'port', 'configPath', 'stateDir', 'serviceName', 'ownership'].includes(key)) {
|
|
84
|
-
throw rerun(`profile ${profileId} contains unsupported field ${JSON.stringify(key)}`);
|
|
85
|
-
}
|
|
86
|
-
}
|
|
87
|
-
if (typeof value.label !== 'string' || !value.label.trim()) throw rerun(`profile ${profileId} has no label`);
|
|
88
|
-
const host = String(value.host || '').toLowerCase();
|
|
89
|
-
if (host !== 'localhost' && host !== '127.0.0.1') throw rerun(`profile ${profileId} uses non-loopback host ${JSON.stringify(value.host)}`);
|
|
90
|
-
if (typeof value.port !== 'number') throw rerun(`profile ${profileId} has invalid port`);
|
|
91
|
-
let port;
|
|
92
|
-
try { port = validPort(value.port); }
|
|
93
|
-
catch { throw rerun(`profile ${profileId} has invalid port`); }
|
|
94
|
-
const configPath = associatedPath(value.configPath, profileId, 'configPath');
|
|
95
|
-
const stateDir = associatedPath(value.stateDir, profileId, 'stateDir');
|
|
96
|
-
const serviceName = typeof value.serviceName === 'string' ? value.serviceName.trim() : '';
|
|
97
|
-
if ((profileId !== 'default' && !serviceName) || (serviceName && !PROFILE_ID.test(serviceName))) {
|
|
98
|
-
throw rerun(`profile ${profileId} has invalid serviceName`);
|
|
99
|
-
}
|
|
100
|
-
const ownership = value.ownership;
|
|
101
|
-
if (!ownership || typeof ownership !== 'object' || Array.isArray(ownership)) throw rerun(`profile ${profileId} has no explicit ownership`);
|
|
102
|
-
for (const key of Object.keys(ownership)) if (!['config', 'service', 'state'].includes(key)) throw rerun(`profile ${profileId} ownership contains unsupported field ${JSON.stringify(key)}`);
|
|
103
|
-
if (typeof ownership.config !== 'boolean' || typeof ownership.service !== 'boolean' || typeof ownership.state !== 'boolean') {
|
|
104
|
-
throw rerun(`profile ${profileId} has invalid ownership`);
|
|
105
|
-
}
|
|
106
|
-
return { ...value, label: value.label.trim(), host: '127.0.0.1', port, configPath, stateDir, serviceName };
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
function associatedProfile(registry, application) {
|
|
110
|
-
if (!registry || typeof registry !== 'object' || Array.isArray(registry)) throw rerun('registry is not an object');
|
|
111
|
-
for (const key of Object.keys(registry)) if (!['version', 'profiles', 'harnessAssociations'].includes(key)) throw rerun(`registry contains unsupported field ${JSON.stringify(key)}`);
|
|
112
|
-
if (registry.version !== 1) throw rerun(`unsupported registry version ${JSON.stringify(registry.version)}`);
|
|
113
|
-
if (!registry.profiles || typeof registry.profiles !== 'object' || Array.isArray(registry.profiles) ||
|
|
114
|
-
!registry.harnessAssociations || typeof registry.harnessAssociations !== 'object' || Array.isArray(registry.harnessAssociations)) {
|
|
115
|
-
throw rerun('registry profiles and harnessAssociations must be objects');
|
|
116
|
-
}
|
|
117
|
-
const profiles = Object.fromEntries(Object.entries(registry.profiles).map(([id, value]) => [id, validateProfile(id, value)]));
|
|
118
|
-
const profileEntries = Object.entries(profiles);
|
|
119
|
-
for (let index = 0; index < profileEntries.length; index += 1) {
|
|
120
|
-
const [id, profile] = profileEntries[index];
|
|
121
|
-
for (const [otherId, other] of profileEntries.slice(index + 1)) {
|
|
122
|
-
const collision = profile.port === other.port ? 'endpoint'
|
|
123
|
-
: resolve(profile.stateDir) === resolve(other.stateDir) ? 'stateDir'
|
|
124
|
-
: profile.configPath === other.configPath ? 'configPath'
|
|
125
|
-
: profile.serviceName === other.serviceName ? 'serviceName' : '';
|
|
126
|
-
if (collision) throw rerun(`profiles ${id} and ${otherId} collide on ${collision}`);
|
|
127
|
-
}
|
|
128
|
-
}
|
|
129
|
-
for (const [key, value] of Object.entries(registry.harnessAssociations)) {
|
|
130
|
-
if (!APPLICATIONS.includes(key) || typeof value !== 'string' || !profiles[value]) {
|
|
131
|
-
throw rerun(`invalid association ${JSON.stringify(key)} -> ${JSON.stringify(value)}`);
|
|
132
|
-
}
|
|
133
|
-
}
|
|
134
|
-
const profileId = registry.harnessAssociations[application];
|
|
135
|
-
if (profileId == null) return null;
|
|
136
|
-
return { profileId, ...profiles[profileId] };
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
export async function resolveDaemonProfile({ argv = [], env = process.env, readConfig, fetch: fetchImpl = globalThis.fetch } = {}) {
|
|
29
|
+
export async function resolveDaemonProfile({ argv = [], env = process.env, fetch: fetchImpl = globalThis.fetch } = {}) {
|
|
140
30
|
const parsed = parseOursArgs(argv);
|
|
141
|
-
const
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
if (!explicitPort && !explicitConfig && !explicitState) {
|
|
148
|
-
const registryPath = env.OURS_INSTALL_PROFILES || join(home, '.ours', 'installer-profiles.json');
|
|
149
|
-
if (env.OURS_INSTALL_PROFILES && (!isAbsolute(registryPath) || normalize(registryPath) !== registryPath || registryPath === '/')) {
|
|
150
|
-
throw rerun('OURS_INSTALL_PROFILES must be a normalized absolute path');
|
|
151
|
-
}
|
|
152
|
-
let registry;
|
|
153
|
-
try { registry = await readRegistryJson(registryPath); }
|
|
154
|
-
catch (error) {
|
|
155
|
-
if (String(error?.message || '').startsWith('ours profile association:')) throw error;
|
|
156
|
-
throw rerun(`cannot read registry ${registryPath}: ${error?.message || error}`);
|
|
157
|
-
}
|
|
158
|
-
if (registry) association = associatedProfile(registry, 'codex');
|
|
159
|
-
}
|
|
160
|
-
const configPath = env.OURS_CONFIG || association?.configPath || defaultConfigPath;
|
|
161
|
-
const config = await (readConfig ? readConfig(configPath) : association
|
|
162
|
-
? readRequiredJson(configPath, `associated config for ${association.profileId}`)
|
|
163
|
-
: readJson(configPath));
|
|
164
|
-
let source = 'default';
|
|
165
|
-
let port = DEFAULT_PORT;
|
|
166
|
-
if (typeof config?.port === 'number' && Number.isFinite(config.port)) {
|
|
167
|
-
port = validPort(config.port);
|
|
168
|
-
source = env.OURS_CONFIG ? 'OURS_CONFIG' : association ? 'registry' : 'config';
|
|
169
|
-
}
|
|
170
|
-
if (association) {
|
|
171
|
-
const configState = resolve(typeof config?.stateDir === 'string' ? config.stateDir : join(home, '.ours'));
|
|
172
|
-
if (port !== association.port || configState !== resolve(association.stateDir)) {
|
|
173
|
-
throw rerun(`profile ${association.profileId} disagrees with ${configPath} on port/stateDir`, 'CONFIG_DRIFT');
|
|
174
|
-
}
|
|
175
|
-
}
|
|
176
|
-
if (env.OURS_PORT != null && env.OURS_PORT !== '') { port = validPort(env.OURS_PORT); source = 'OURS_PORT'; }
|
|
177
|
-
if (parsed.port != null) { port = parsed.port; source = '--ours-port'; }
|
|
31
|
+
const selection = resolveDaemonConfig({
|
|
32
|
+
...(parsed.port == null ? {} : { port: parsed.port }),
|
|
33
|
+
env,
|
|
34
|
+
homeDir: env.HOME || homedir(),
|
|
35
|
+
});
|
|
36
|
+
await assertDaemonStateDir(selection, { fetch: fetchImpl, timeoutMs: 2000 });
|
|
178
37
|
|
|
179
|
-
const baseUrl =
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
}
|
|
185
|
-
if (!response.ok) throw new Error(`ours daemon on port ${port} returned HTTP ${response.status}`);
|
|
38
|
+
const baseUrl = selection.baseUrl.value;
|
|
39
|
+
const token = selection.token?.value ?? null;
|
|
40
|
+
const headers = token ? { 'x-ours-api-token': token } : {};
|
|
41
|
+
const response = await fetchImpl(`${baseUrl}/info`, { signal: AbortSignal.timeout(2000) });
|
|
42
|
+
if (!response.ok) throw new Error(`ours daemon at ${baseUrl} returned HTTP ${response.status}`);
|
|
186
43
|
const info = await response.json();
|
|
187
|
-
if (info?.name !== 'ours' || !Number.isInteger(info?.protocol) || info.protocol < 1
|
|
188
|
-
throw new Error(`incompatible service
|
|
44
|
+
if (info?.name !== 'ours' || !Number.isInteger(info?.protocol) || info.protocol < 1) {
|
|
45
|
+
throw new Error(`incompatible service at ${baseUrl}; expected an ours daemon with notification protocol 1`);
|
|
189
46
|
}
|
|
190
|
-
const
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
47
|
+
for (const path of ['/identities', '/unread']) {
|
|
48
|
+
const capability = await fetchImpl(`${baseUrl}${path}`, { headers, signal: AbortSignal.timeout(2000) });
|
|
49
|
+
if (capability.status === 401 || capability.status === 403) {
|
|
50
|
+
throw new Error(`ours daemon authentication failed at ${baseUrl}; supply the matching coherent selection`);
|
|
51
|
+
}
|
|
52
|
+
if (!capability.ok) throw new Error(`selected daemon lacks ${path} (HTTP ${capability.status})`);
|
|
196
53
|
}
|
|
197
|
-
const selectedStateDir = expectedStateDir || stateDir;
|
|
198
|
-
const token = env.OURS_API_TOKEN?.trim() || config?.apiToken?.trim() || await readOwnerToken(selectedStateDir);
|
|
199
|
-
const headers = token ? { 'x-ours-api-token': token } : {};
|
|
200
|
-
let capability;
|
|
201
|
-
try { capability = await fetchImpl(`${baseUrl}/identities`, { headers, signal: AbortSignal.timeout(2000) }); }
|
|
202
|
-
catch (error) { throw new Error(`ours daemon capability check failed: ${error?.message || error}`); }
|
|
203
|
-
if (capability.status === 401 || capability.status === 403) throw new Error(`ours daemon authentication failed on port ${port}; supply the matching OURS_API_TOKEN/config`);
|
|
204
|
-
if (!capability.ok) throw new Error(`selected daemon lacks the notification API (HTTP ${capability.status})`);
|
|
205
|
-
let unread;
|
|
206
|
-
try { unread = await fetchImpl(`${baseUrl}/unread`, { headers, signal: AbortSignal.timeout(2000) }); }
|
|
207
|
-
catch (error) { throw new Error(`ours daemon unread capability check failed: ${error?.message || error}`); }
|
|
208
|
-
if (unread.status === 401 || unread.status === 403) throw new Error(`ours daemon authentication failed on port ${port}; supply the matching OURS_API_TOKEN/config`);
|
|
209
|
-
if (!unread.ok) throw new Error(`selected daemon lacks the body-free unread API (HTTP ${unread.status}); install the testing daemon build and restart that daemon explicitly`);
|
|
210
54
|
|
|
211
55
|
return {
|
|
212
|
-
port
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
56
|
+
port: selection.port.value,
|
|
57
|
+
stateDir: selection.expectStateDir,
|
|
58
|
+
token,
|
|
59
|
+
visibility: 'owner',
|
|
60
|
+
source: selection.baseUrl.source,
|
|
61
|
+
info,
|
|
62
|
+
baseUrl,
|
|
63
|
+
configPath: selection.configPath?.value ?? join(selection.expectStateDir, 'config.json'),
|
|
64
|
+
codexArgs: parsed.codexArgs,
|
|
216
65
|
};
|
|
217
66
|
}
|