agentcoor 0.0.0-stage → 0.3.7
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 +104 -2
- package/bin/agentcoor.js +6 -0
- package/package.json +29 -4
- package/src/cli.js +334 -0
- package/src/config.js +67 -0
- package/src/connect.js +351 -0
- package/src/fsx.js +125 -0
- package/src/harness.js +138 -0
- package/src/http.js +61 -0
- package/src/market.js +321 -0
- package/src/release-floor.json +3 -0
- package/src/release.js +184 -0
- package/src/sha.js +24 -0
- package/src/share.js +222 -0
- package/src/signature.js +27 -0
- package/src/toml.js +137 -0
package/README.md
CHANGED
|
@@ -1,3 +1,105 @@
|
|
|
1
|
-
#
|
|
1
|
+
# agentcoor
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Command-line installer and helper for [AgentCoor](https://agentcoor.nionto.com).
|
|
4
|
+
It connects a machine to your AgentCoor dashboard and points your agent harnesses
|
|
5
|
+
(Claude Code, Codex, OpenClaude, Cursor, or plain environment variables) at AgentCoor models.
|
|
6
|
+
|
|
7
|
+
Requires Node.js 20 or newer. No runtime dependencies.
|
|
8
|
+
|
|
9
|
+
## Connect a machine
|
|
10
|
+
|
|
11
|
+
In the dashboard open **Hosts -> Connect a machine** to get a host ID and token, then run:
|
|
12
|
+
|
|
13
|
+
```sh
|
|
14
|
+
npx agentcoor connect --host-id <id>
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
The host token is read from a hidden prompt. In scripts, pipe it on stdin, pass `--token-file <path>`,
|
|
18
|
+
or set `AGENTCOOR_HOST_TOKEN`. (`--token` still works but is insecure: anything on the command line can end up
|
|
19
|
+
in shell history and process listings, so the CLI prints a warning.)
|
|
20
|
+
|
|
21
|
+
This downloads the agent for your OS and CPU (macOS or Linux, x64 or arm64; on Windows run it inside WSL),
|
|
22
|
+
checks the Ed25519 signature on the published release manifest (`release.json`) against the AgentCoor release key
|
|
23
|
+
built into this CLI, refuses expired manifests and releases older than one this machine already installed, then
|
|
24
|
+
verifies the binary's size and SHA-256 against that signed manifest (the install stops on any failure), installs it to
|
|
25
|
+
`~/Library/Application Support/AgentCoor` on macOS or `~/.local/share/agentcoor` on Linux (the same place `install.sh`
|
|
26
|
+
uses; an earlier `~/.agentcoor` agent is stopped and removed, and its session state is not carried over, so reconnect
|
|
27
|
+
any sessions you need),
|
|
28
|
+
writes a private config (`agent.json`, mode 600) there,
|
|
29
|
+
and runs it as a per-user background service (launchd on macOS, `systemd --user` on Linux).
|
|
30
|
+
If Claude Code or Codex is installed, AgentCoor tools are added to them; re-running updates the existing
|
|
31
|
+
entries instead of adding duplicates.
|
|
32
|
+
|
|
33
|
+
`AGENTCOOR_HOST_ID` and `AGENTCOOR_HOST_TOKEN` work in place of the flags (the token variable is removed from the
|
|
34
|
+
environment before any other program is started).
|
|
35
|
+
|
|
36
|
+
Advanced: to use the shell installer instead, let this package verify it first. `install.sh` is authenticated
|
|
37
|
+
through the signed release manifest (`release.json` lists its SHA-256 and size), so it inherits the manifest's
|
|
38
|
+
expiry and rollback floor:
|
|
39
|
+
|
|
40
|
+
```sh
|
|
41
|
+
npx agentcoor verify-installer --download --host-id <id> # verifies, stages in a private temp dir, runs it there
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Without `--host-id` it only verifies and prints the path of the private staged copy.
|
|
45
|
+
|
|
46
|
+
## Use AgentCoor models
|
|
47
|
+
|
|
48
|
+
Create an API key in the dashboard (**API & Credits** tab), then:
|
|
49
|
+
|
|
50
|
+
```sh
|
|
51
|
+
npm install -g agentcoor # or prefix each command with npx
|
|
52
|
+
agentcoor login # paste the key at the hidden prompt (or: --key-file <path>, stdin, AGENTCOOR_API_KEY)
|
|
53
|
+
agentcoor test # one tiny chat completion
|
|
54
|
+
agentcoor models
|
|
55
|
+
agentcoor credits
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
The key is stored in `~/.agentcoor/config.json` with mode 600. The CLI does not echo it (only a masked form
|
|
59
|
+
like `bbk_…abcd`), unless you ask for it with `setup --show-key`. Avoid `--key <value>`: command-line arguments are
|
|
60
|
+
visible to other tools on the machine and saved in shell history.
|
|
61
|
+
|
|
62
|
+
### Harness setup
|
|
63
|
+
|
|
64
|
+
```sh
|
|
65
|
+
agentcoor setup claude # Claude Code
|
|
66
|
+
agentcoor setup codex # Codex
|
|
67
|
+
agentcoor setup openclaude # OpenClaude
|
|
68
|
+
agentcoor setup cursor # Cursor (printed steps only)
|
|
69
|
+
agentcoor setup env # shell exports for any OpenAI- or Anthropic-compatible client
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
By default this only prints the settings (with a placeholder key; add `--show-key` to include your key).
|
|
73
|
+
Add `--write` to update the harness config file. Existing AgentCoor entries are replaced, not duplicated;
|
|
74
|
+
the previous file is first backed up (`<file>.agentcoor-backup-<timestamp>-<random>`, mode 600), and a file that
|
|
75
|
+
is not valid JSON/TOML is left untouched.
|
|
76
|
+
|
|
77
|
+
| Harness | Written to |
|
|
78
|
+
| --- | --- |
|
|
79
|
+
| claude | `env` block of `~/.claude/settings.json` |
|
|
80
|
+
| codex | `[model_providers.agentcoor]` and `[profiles.agentcoor]` in `~/.codex/config.toml` (run `codex --profile agentcoor`) |
|
|
81
|
+
| openclaude, env | `~/.agentcoor/env.sh` (add `source ~/.agentcoor/env.sh` to your shell profile) |
|
|
82
|
+
|
|
83
|
+
Endpoints:
|
|
84
|
+
|
|
85
|
+
- OpenAI-compatible: `https://agentcoor.nionto.com/v1`
|
|
86
|
+
- Anthropic Messages: base URL `https://agentcoor.nionto.com` (clients call `/v1/messages`)
|
|
87
|
+
|
|
88
|
+
Models: `agentcoor-auto` (default, best route for the task), `agentcoor-fast` (frontier-class reasoning),
|
|
89
|
+
`agentcoor-compute` (AgentCoor-hosted open models, lowest cost). Claude model names map automatically:
|
|
90
|
+
opus -> agentcoor-fast, haiku -> agentcoor-compute, others -> agentcoor-auto.
|
|
91
|
+
|
|
92
|
+
## Options
|
|
93
|
+
|
|
94
|
+
- `--gateway <url>` or `AGENTCOOR_GATEWAY_URL`: use a different AgentCoor deployment (HTTPS required).
|
|
95
|
+
- `AGENTCOOR_HOME`: CLI config directory (default `~/.agentcoor`).
|
|
96
|
+
|
|
97
|
+
All requests use HTTPS with certificate verification, a timeout, no redirects, and size limits
|
|
98
|
+
(1 MB for API responses and checksum files, 200 MB for the agent binary).
|
|
99
|
+
|
|
100
|
+
## Development
|
|
101
|
+
|
|
102
|
+
```sh
|
|
103
|
+
npm test
|
|
104
|
+
npm run verify-pack # packs the tarball and checks it contains exactly the expected files
|
|
105
|
+
```
|
package/bin/agentcoor.js
ADDED
package/package.json
CHANGED
|
@@ -1,6 +1,31 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agentcoor",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.3.7",
|
|
4
|
+
"description": "AgentCoor CLI: connect a machine and point agent harnesses at AgentCoor models",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"agentcoor": "bin/agentcoor.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"bin",
|
|
11
|
+
"src",
|
|
12
|
+
"README.md"
|
|
13
|
+
],
|
|
14
|
+
"engines": {
|
|
15
|
+
"node": ">=20"
|
|
16
|
+
},
|
|
17
|
+
"scripts": {
|
|
18
|
+
"test": "node --test test/*.test.js",
|
|
19
|
+
"verify-pack": "node scripts/verify-pack.js",
|
|
20
|
+
"prepublishOnly": "node scripts/verify-pack.js --publish"
|
|
21
|
+
},
|
|
22
|
+
"homepage": "https://agentcoor.nionto.com",
|
|
23
|
+
"license": "UNLICENSED",
|
|
24
|
+
"keywords": [
|
|
25
|
+
"agentcoor",
|
|
26
|
+
"cli",
|
|
27
|
+
"agents",
|
|
28
|
+
"claude-code",
|
|
29
|
+
"codex"
|
|
30
|
+
]
|
|
31
|
+
}
|
package/src/cli.js
ADDED
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
import { parseArgs } from "node:util";
|
|
2
|
+
import { open, mkdtemp, rm } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join, dirname } from "node:path";
|
|
5
|
+
import { spawnSync } from "node:child_process";
|
|
6
|
+
import { createHash, timingSafeEqual } from "node:crypto";
|
|
7
|
+
import { constants } from "node:fs";
|
|
8
|
+
import { createInterface } from "node:readline";
|
|
9
|
+
import { Writable } from "node:stream";
|
|
10
|
+
import { readConfig, writeConfig, resolveSettings, isApiKey, maskKey, configPath, baseUrls, installPaths } from "./config.js";
|
|
11
|
+
import { apiJson, errorMessage, download, MAX_API_BYTES } from "./http.js";
|
|
12
|
+
import { plan, applyPlan, HARNESSES } from "./harness.js";
|
|
13
|
+
import { connect, SECRET_ENV, childEnv, HOST_ID } from "./connect.js";
|
|
14
|
+
import { keyFingerprint, RELEASE_PUBLIC_KEY } from "./signature.js";
|
|
15
|
+
import { verifyManifest, installerFor, verifyArtifact, readAcceptedEpoch } from "./release.js";
|
|
16
|
+
import { openExclusive } from "./fsx.js";
|
|
17
|
+
import { shareModel, shareCommand } from "./share.js";
|
|
18
|
+
import { listAgent, listTools, jobsCommand, sessionsCommand } from "./market.js";
|
|
19
|
+
|
|
20
|
+
const HELP = `agentcoor - connect machines and agent harnesses to AgentCoor
|
|
21
|
+
|
|
22
|
+
Usage:
|
|
23
|
+
agentcoor connect --host-id <id> [--token-file <path>]
|
|
24
|
+
Install the agent on this machine and enroll it
|
|
25
|
+
(token: hidden prompt, stdin, --token-file or AGENTCOOR_HOST_TOKEN)
|
|
26
|
+
agentcoor login [--key-file <path>] Save your AgentCoor API key
|
|
27
|
+
(key: hidden prompt, stdin, --key-file or AGENTCOOR_API_KEY)
|
|
28
|
+
agentcoor verify-installer [install.sh] [--manifest <release.json>] [--download] [--host-id <id>]
|
|
29
|
+
Check install.sh against the signed release manifest
|
|
30
|
+
(signature, expiry, epoch floor, SHA-256 and size),
|
|
31
|
+
stage the verified bytes in a private directory, and
|
|
32
|
+
run them there when --host-id is given
|
|
33
|
+
(--download fetches all three from the gateway)
|
|
34
|
+
agentcoor logout Remove the saved API key
|
|
35
|
+
agentcoor setup <${HARNESSES.join("|")}> [--write] [--show-key]
|
|
36
|
+
Print (or write) harness config for AgentCoor
|
|
37
|
+
agentcoor models List the models your key can use
|
|
38
|
+
agentcoor credits Show your credit balance
|
|
39
|
+
agentcoor test [--model agentcoor-auto] Send one tiny chat completion
|
|
40
|
+
agentcoor share-model --endpoint <url> --model <name> [--context N] [--max-concurrency N] [--private]
|
|
41
|
+
Share a local OpenAI-compatible endpoint (loopback or
|
|
42
|
+
tailnet only) and earn points; installs agentcoor-share
|
|
43
|
+
agentcoor share <pause|resume|withdraw|status|usage|earnings> [--listing lst_...]
|
|
44
|
+
Manage the model shared from this machine
|
|
45
|
+
agentcoor <pause|resume|withdraw> [lst_...] Aliases for agentcoor share <pause|resume|withdraw>
|
|
46
|
+
agentcoor earnings Alias for agentcoor share earnings
|
|
47
|
+
agentcoor list-agent --description <text> --per-job-microusd N [--per-minute-microusd N]
|
|
48
|
+
[--sandbox-config sandbox.json] [--harness claude-code] [--max-duration S]
|
|
49
|
+
[--egress host1,host2] [--public]
|
|
50
|
+
Linux only: probe sandbox isolation, enroll this machine
|
|
51
|
+
as a marketplace worker, list an agent for batch jobs
|
|
52
|
+
and install the agentcoor-worker service
|
|
53
|
+
agentcoor list-tools --tool-config tools.json [--sandbox-config sandbox.json] [--public]
|
|
54
|
+
Linux only: list live tools (JSON schemas) on the same worker
|
|
55
|
+
agentcoor jobs submit --listing lst_... (--task <text> | --task-file <path>) --max-spend <microusd>
|
|
56
|
+
agentcoor jobs <get|cancel|artifacts> job_... Rent an agent for a batch job (paid credit only)
|
|
57
|
+
agentcoor sessions open --listing lst_... --max-spend <microusd>
|
|
58
|
+
agentcoor sessions call rss_... --tool <name> [--args '<json>' | --args-file <path>]
|
|
59
|
+
agentcoor sessions close rss_... Rent live tools by the call or minute
|
|
60
|
+
|
|
61
|
+
Options:
|
|
62
|
+
--gateway <url> AgentCoor URL (default https://agentcoor.nionto.com, or AGENTCOOR_GATEWAY_URL)
|
|
63
|
+
--key-file <path> Read the API key from a file (default: AGENTCOOR_API_KEY, then ~/.agentcoor/config.json)
|
|
64
|
+
--key, --token Insecure: secrets on the command line leak into shell history and process listings
|
|
65
|
+
|
|
66
|
+
Create API keys in the dashboard: API & Credits tab.
|
|
67
|
+
`;
|
|
68
|
+
|
|
69
|
+
const OPTIONS = {
|
|
70
|
+
gateway: { type: "string" }, key: { type: "string" }, "key-file": { type: "string" }, "host-id": { type: "string" }, token: { type: "string" }, "token-file": { type: "string" },
|
|
71
|
+
write: { type: "boolean" }, "show-key": { type: "boolean" }, model: { type: "string" }, "no-verify": { type: "boolean" },
|
|
72
|
+
manifest: { type: "string" }, download: { type: "boolean" },
|
|
73
|
+
endpoint: { type: "string" }, context: { type: "string" }, "max-concurrency": { type: "string" }, private: { type: "boolean" },
|
|
74
|
+
description: { type: "string" }, "per-job-microusd": { type: "string" }, "per-minute-microusd": { type: "string" }, harness: { type: "string" },
|
|
75
|
+
"max-duration": { type: "string" }, cpu: { type: "string" }, "memory-mb": { type: "string" }, "disk-mb": { type: "string" }, egress: { type: "string" },
|
|
76
|
+
"sandbox-config": { type: "string" }, "tool-config": { type: "string" }, public: { type: "boolean" },
|
|
77
|
+
task: { type: "string" }, "task-file": { type: "string" }, "max-spend": { type: "string" }, "idempotency-key": { type: "string" },
|
|
78
|
+
tool: { type: "string" }, args: { type: "string" }, "args-file": { type: "string" },
|
|
79
|
+
"price-input": { type: "string" }, "price-output": { type: "string" }, "fleet-url": { type: "string" }, listing: { type: "string" }, replace: { type: "boolean" },
|
|
80
|
+
help: { type: "boolean", short: "h" }, version: { type: "boolean", short: "v" },
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
const ARGV_WARNING = (flag) => `warning: --${flag} puts a secret on the command line, where shell history, process listings and logs can capture it. Prefer the hidden prompt, stdin, or --${flag}-file.\n`;
|
|
84
|
+
|
|
85
|
+
async function readStdin(stdin) {
|
|
86
|
+
let s = "";
|
|
87
|
+
for await (const chunk of stdin) { s += chunk; if (s.length > 64 * 1024) throw new Error("stdin input is too large"); }
|
|
88
|
+
return s.trim();
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function hiddenPrompt(question, stdin, stderr) {
|
|
92
|
+
let muted = false;
|
|
93
|
+
const output = new Writable({ write(chunk, _enc, cb) { if (!muted) stderr.write(chunk); cb(); } });
|
|
94
|
+
const rl = createInterface({ input: stdin, output, terminal: true });
|
|
95
|
+
return new Promise((resolve) => {
|
|
96
|
+
rl.question(question, (a) => { rl.close(); stderr.write("\n"); resolve(a.trim()); });
|
|
97
|
+
muted = true;
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export const MAX_SECRET_FILE = 64 * 1024;
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Read a small file through one descriptor: O_NOFOLLOW (no symlinks), O_NONBLOCK
|
|
105
|
+
* (a FIFO cannot hang us), fstat before reading (regular file, at most `max`
|
|
106
|
+
* bytes; for secrets also owned by us with no group/world permission bits), then
|
|
107
|
+
* a bounded read.
|
|
108
|
+
*/
|
|
109
|
+
export async function readSmallFile(path, max = MAX_SECRET_FILE, { requireOwner = true } = {}) {
|
|
110
|
+
let fh;
|
|
111
|
+
try { fh = await open(path, constants.O_RDONLY | (constants.O_NOFOLLOW || 0) | (constants.O_NONBLOCK || 0)); }
|
|
112
|
+
catch (e) { throw new Error(e.code === "ELOOP" ? `refusing to read ${path}: it is a symbolic link` : `cannot read ${path}: ${e.code || e.message}`); }
|
|
113
|
+
try {
|
|
114
|
+
const st = await fh.stat();
|
|
115
|
+
if (!st.isFile()) throw new Error(`refusing to read ${path}: not a regular file`);
|
|
116
|
+
if (requireOwner && typeof process.getuid === "function" && st.uid !== process.getuid()) throw new Error(`refusing to read ${path}: not owned by you`);
|
|
117
|
+
if (requireOwner && (st.mode & 0o077)) throw new Error(`refusing to read ${path}: it is accessible to group or others (chmod 600 ${path})`);
|
|
118
|
+
if (st.size > max) throw new Error(`${path} is too large (limit ${max} bytes)`);
|
|
119
|
+
const buf = Buffer.alloc(max + 1);
|
|
120
|
+
let n = 0;
|
|
121
|
+
for (;;) {
|
|
122
|
+
const { bytesRead } = await fh.read(buf, n, buf.length - n, n);
|
|
123
|
+
if (bytesRead === 0) break;
|
|
124
|
+
n += bytesRead;
|
|
125
|
+
if (n > max) throw new Error(`${path} is too large (limit ${max} bytes)`);
|
|
126
|
+
}
|
|
127
|
+
return buf.subarray(0, n);
|
|
128
|
+
} finally { await fh.close(); }
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
async function readSecretFile(path) {
|
|
132
|
+
return (await readSmallFile(path)).toString("utf8").trim();
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Secret precedence: --x-file, then --x (warned), then the env var, then a hidden prompt (TTY) or stdin. */
|
|
136
|
+
async function resolveSecret({ file, flag, flagName, envValue, prompt, io, err }) {
|
|
137
|
+
if (file) return readSecretFile(file);
|
|
138
|
+
if (flag) { err(ARGV_WARNING(flagName)); return flag.trim(); }
|
|
139
|
+
if (envValue) return envValue.trim();
|
|
140
|
+
if (io.stdin.isTTY) return hiddenPrompt(prompt, io.stdin, io.stderr);
|
|
141
|
+
return readStdin(io.stdin);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
async function requireKey(flags, env, err) {
|
|
145
|
+
if (flags["key-file"]) flags = { ...flags, key: await readSecretFile(flags["key-file"]) };
|
|
146
|
+
else if (flags.key) err(ARGV_WARNING("key"));
|
|
147
|
+
const s = await resolveSettings(flags, env);
|
|
148
|
+
if (!s.apiKey) throw new Error("no API key: run `agentcoor login` or set AGENTCOOR_API_KEY (create one in the dashboard, API & Credits tab)");
|
|
149
|
+
if (!isApiKey(s.apiKey)) throw new Error("the API key does not look like an AgentCoor key (bbk_...)");
|
|
150
|
+
return s;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Fetch (or read) install.sh plus release.json(.sig) and authenticate install.sh
|
|
155
|
+
* through the signed manifest: signature, expiry, epoch >= this CLI's floor and
|
|
156
|
+
* >= the epoch already accepted on this machine, then its SHA-256 and size.
|
|
157
|
+
*/
|
|
158
|
+
export async function loadInstaller({ download: dl, origin, path, manifestPath, env = process.env, fetcher = download, publicKey = RELEASE_PUBLIC_KEY, minEpoch, now } = {}) {
|
|
159
|
+
let script, mbuf, sig;
|
|
160
|
+
if (dl) {
|
|
161
|
+
[script, mbuf, sig] = await Promise.all(["install.sh", "release.json", "release.json.sig"].map((n) => fetcher(`${origin}/download/${n}`, { maxBytes: MAX_API_BYTES })));
|
|
162
|
+
} else {
|
|
163
|
+
const sh = path || "install.sh";
|
|
164
|
+
const mp = manifestPath || join(dirname(sh), "release.json");
|
|
165
|
+
script = await readSmallFile(sh, MAX_API_BYTES, { requireOwner: false });
|
|
166
|
+
mbuf = await readSmallFile(mp, MAX_API_BYTES, { requireOwner: false });
|
|
167
|
+
sig = await readSmallFile(`${mp}.sig`, 1024, { requireOwner: false });
|
|
168
|
+
}
|
|
169
|
+
const acceptedEpoch = await readAcceptedEpoch(installPaths(env).base);
|
|
170
|
+
const manifest = verifyManifest(mbuf, sig, { publicKey, acceptedEpoch, ...(minEpoch ? { minEpoch } : {}), ...(now ? { now } : {}) });
|
|
171
|
+
verifyArtifact(script, installerFor(manifest));
|
|
172
|
+
return { script, manifest };
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Write verified bytes into a fresh private directory (mkdtemp, mode 700) through an
|
|
177
|
+
* exclusive, no-follow descriptor, read them back through that descriptor and
|
|
178
|
+
* re-check the hash, so what `sh` runs is exactly what was verified.
|
|
179
|
+
*/
|
|
180
|
+
export async function stageInstaller(script) {
|
|
181
|
+
const dir = await mkdtemp(join(tmpdir(), "agentcoor-installer-"));
|
|
182
|
+
const path = join(dir, "install.sh");
|
|
183
|
+
const want = createHash("sha256").update(script).digest();
|
|
184
|
+
const fh = await openExclusive(path, 0o600);
|
|
185
|
+
try {
|
|
186
|
+
await fh.writeFile(script);
|
|
187
|
+
await fh.sync();
|
|
188
|
+
const back = Buffer.alloc(script.length + 1);
|
|
189
|
+
const { bytesRead } = await fh.read(back, 0, back.length, 0);
|
|
190
|
+
if (bytesRead !== script.length || !timingSafeEqual(createHash("sha256").update(back.subarray(0, bytesRead)).digest(), want)) throw new Error("staged installer does not match the verified bytes");
|
|
191
|
+
} catch (e) { await rm(dir, { recursive: true, force: true }); throw e; }
|
|
192
|
+
finally { await fh.close(); }
|
|
193
|
+
return { dir, path };
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
export async function main(argv, { env = process.env, out = (s) => process.stdout.write(s), err = (s) => process.stderr.write(s), io = { stdin: process.stdin, stderr: process.stderr } } = {}) {
|
|
197
|
+
const { values: f, positionals } = parseArgs({ args: argv, options: OPTIONS, allowPositionals: true, strict: true });
|
|
198
|
+
const [cmd, arg, arg2] = positionals;
|
|
199
|
+
// Secrets taken from the environment are copied, then removed so no child process inherits them.
|
|
200
|
+
const scrubbed = {};
|
|
201
|
+
for (const k of SECRET_ENV) if (env[k] !== undefined) { scrubbed[k] = env[k]; delete env[k]; if (env !== process.env) delete process.env[k]; }
|
|
202
|
+
env = { ...env, ...scrubbed };
|
|
203
|
+
if (f.version) { out("0.1.0\n"); return 0; }
|
|
204
|
+
if (f.help || !cmd || cmd === "help") { out(HELP); return 0; }
|
|
205
|
+
|
|
206
|
+
switch (cmd) {
|
|
207
|
+
case "connect": {
|
|
208
|
+
const { origin } = await resolveSettings({ gateway: f.gateway }, env);
|
|
209
|
+
const hostId = f["host-id"] || env.AGENTCOOR_HOST_ID;
|
|
210
|
+
if (!hostId) throw new Error("usage: agentcoor connect --host-id <id> (copy it from the dashboard: Hosts -> Connect a machine)");
|
|
211
|
+
const hostToken = await resolveSecret({ file: f["token-file"], flag: f.token, flagName: "token", envValue: env.AGENTCOOR_HOST_TOKEN, prompt: "Host token (input hidden): ", io, err });
|
|
212
|
+
await connect({ origin, hostId, hostToken, env, log: (s) => out(s + "\n") });
|
|
213
|
+
return 0;
|
|
214
|
+
}
|
|
215
|
+
case "login": {
|
|
216
|
+
const key = await resolveSecret({ file: f["key-file"], flag: f.key, flagName: "key", envValue: env.AGENTCOOR_API_KEY, prompt: "AgentCoor API key (input hidden): ", io, err });
|
|
217
|
+
if (!key) throw new Error("usage: agentcoor login (then paste the key at the hidden prompt), or pipe it on stdin, or use --key-file <path>");
|
|
218
|
+
if (!isApiKey(key)) throw new Error("that does not look like an AgentCoor API key (expected bbk_...)");
|
|
219
|
+
const cfg = await readConfig(env);
|
|
220
|
+
const { origin } = await resolveSettings({ gateway: f.gateway }, env);
|
|
221
|
+
if (!f["no-verify"]) {
|
|
222
|
+
const r = await apiJson(origin, "/v1/models", key, { timeoutMs: 15_000 });
|
|
223
|
+
if (r.status === 401) throw new Error("AgentCoor rejected this key (it may have been revoked)");
|
|
224
|
+
if (!r.ok) err(`warning: could not verify the key (${errorMessage(r)}); saving anyway\n`);
|
|
225
|
+
}
|
|
226
|
+
await writeConfig({ ...cfg, gateway: origin, apiKey: key }, env);
|
|
227
|
+
out(`Saved API key ${maskKey(key)} to ${configPath(env)} (mode 600).\n`);
|
|
228
|
+
return 0;
|
|
229
|
+
}
|
|
230
|
+
case "verify-installer": {
|
|
231
|
+
const hostId = f["host-id"];
|
|
232
|
+
if (hostId && !HOST_ID.test(hostId)) throw new Error("the host ID contains unexpected characters; copy it again from the dashboard");
|
|
233
|
+
const { origin } = await resolveSettings({ gateway: f.gateway }, env);
|
|
234
|
+
const { script, manifest } = await loadInstaller({ download: f.download, origin, path: arg, manifestPath: f.manifest, env });
|
|
235
|
+
const staged = await stageInstaller(script);
|
|
236
|
+
out(`OK: install.sh is listed in signed release ${manifest.version} (epoch ${manifest.epoch}, expires ${manifest.expires_at}; key sha256:${keyFingerprint()}).\n`);
|
|
237
|
+
if (!hostId) {
|
|
238
|
+
out(`Verified copy staged at ${staged.path} (private directory).\nRun it with: sh ${staged.path} --host-id <id>${f.gateway ? ` --gateway ${origin}` : ""}\n`);
|
|
239
|
+
return 0;
|
|
240
|
+
}
|
|
241
|
+
try {
|
|
242
|
+
const r = spawnSync("sh", [staged.path, "--host-id", hostId, ...(f.gateway ? ["--gateway", origin] : [])], { stdio: "inherit", env: childEnv(env) });
|
|
243
|
+
return r.status ?? 1;
|
|
244
|
+
} finally { await rm(staged.dir, { recursive: true, force: true }); }
|
|
245
|
+
}
|
|
246
|
+
case "logout": {
|
|
247
|
+
const cfg = await readConfig(env);
|
|
248
|
+
delete cfg.apiKey;
|
|
249
|
+
await writeConfig(cfg, env);
|
|
250
|
+
out("Removed the saved API key.\n");
|
|
251
|
+
return 0;
|
|
252
|
+
}
|
|
253
|
+
case "setup": {
|
|
254
|
+
if (!arg) throw new Error(`usage: agentcoor setup <${HARNESSES.join("|")}> [--write]`);
|
|
255
|
+
const s = (f.write || f["show-key"]) ? await requireKey(f, env, err) : await resolveSettings(f, env);
|
|
256
|
+
const displayKey = f["show-key"] && s.apiKey ? s.apiKey : "bbk_your_key";
|
|
257
|
+
const p = plan(arg, { origin: s.origin, key: s.apiKey, displayKey, env });
|
|
258
|
+
out(p.print);
|
|
259
|
+
if (f.write) {
|
|
260
|
+
const r = await applyPlan(p);
|
|
261
|
+
if (!r) out(`\n${p.file} already points at AgentCoor; nothing changed.\n`);
|
|
262
|
+
else out(`\nWrote ${r.path}${r.backup ? ` (backup: ${r.backup})` : ""}.\n`);
|
|
263
|
+
} else if (p.file) out(`\nRun with --write to update ${p.file} (a backup is made first).\n`);
|
|
264
|
+
return 0;
|
|
265
|
+
}
|
|
266
|
+
case "models": {
|
|
267
|
+
const s = await requireKey(f, env, err);
|
|
268
|
+
const r = await apiJson(s.origin, "/v1/models", s.apiKey);
|
|
269
|
+
if (!r.ok) throw new Error(`models: ${errorMessage(r)}`);
|
|
270
|
+
for (const m of r.data?.data || []) out(`${m.id.padEnd(20)} ${m.description || ""}${m.context_length ? ` (${m.context_length.toLocaleString("en-US")} tokens)` : ""}\n`);
|
|
271
|
+
return 0;
|
|
272
|
+
}
|
|
273
|
+
case "credits": {
|
|
274
|
+
const s = await requireKey(f, env, err);
|
|
275
|
+
const r = await apiJson(s.origin, "/v1/credits", s.apiKey);
|
|
276
|
+
if (r.ok) {
|
|
277
|
+
const d = r.data || {};
|
|
278
|
+
const usd = (v) => (typeof v === "number" ? `$${(v / 1e6).toFixed(2)}` : "n/a");
|
|
279
|
+
out(`Balance: ${usd(d.balanceMicrousd)}\nAvailable: ${usd(d.availableMicrousd)}\nReserved: ${usd(d.reservedMicrousd)}\n`);
|
|
280
|
+
return 0;
|
|
281
|
+
}
|
|
282
|
+
if (r.status === 401) throw new Error("credits: AgentCoor rejected this key");
|
|
283
|
+
out(`Credit balances are not available to API keys yet.\nSee your balance and add credit at ${s.origin} (API & Credits tab).\n`);
|
|
284
|
+
return 0;
|
|
285
|
+
}
|
|
286
|
+
case "test": {
|
|
287
|
+
const s = await requireKey(f, env, err);
|
|
288
|
+
const model = f.model || "agentcoor-auto";
|
|
289
|
+
const t0 = Date.now();
|
|
290
|
+
const r = await apiJson(s.origin, "/v1/chat/completions", s.apiKey, {
|
|
291
|
+
method: "POST", timeoutMs: 60_000,
|
|
292
|
+
body: { model, max_tokens: 16, messages: [{ role: "user", content: "Reply with the single word: ok" }] },
|
|
293
|
+
});
|
|
294
|
+
if (!r.ok) {
|
|
295
|
+
if (r.status === 402) throw new Error(`test: out of credit. Add credit at ${s.origin} (API & Credits tab)`);
|
|
296
|
+
throw new Error(`test: ${errorMessage(r)}`);
|
|
297
|
+
}
|
|
298
|
+
const reply = r.data?.choices?.[0]?.message?.content ?? "";
|
|
299
|
+
out(`OK ${model} ${Date.now() - t0} ms reply: ${JSON.stringify(String(reply).trim().slice(0, 80))}\n`);
|
|
300
|
+
if (r.data?.usage) out(` tokens: ${r.data.usage.prompt_tokens} in / ${r.data.usage.completion_tokens} out\n`);
|
|
301
|
+
out(` OpenAI base URL ${baseUrls(s.origin).openai}, Anthropic base URL ${baseUrls(s.origin).anthropic}\n`);
|
|
302
|
+
return 0;
|
|
303
|
+
}
|
|
304
|
+
case "share-model": {
|
|
305
|
+
const s = await requireKey(f, env, err);
|
|
306
|
+
await shareModel({ origin: s.origin, apiKey: s.apiKey, flags: f, env, log: (m) => out(m + "\n") });
|
|
307
|
+
return 0;
|
|
308
|
+
}
|
|
309
|
+
case "share": {
|
|
310
|
+
const s = await requireKey(f, env, err);
|
|
311
|
+
return shareCommand(arg, { origin: s.origin, apiKey: s.apiKey, flags: f, env, out });
|
|
312
|
+
}
|
|
313
|
+
// Top-level aliases shown in the dashboard: `agentcoor pause <listing>`, `agentcoor earnings`.
|
|
314
|
+
case "pause": case "resume": case "withdraw": case "earnings": {
|
|
315
|
+
const s = await requireKey(f, env, err);
|
|
316
|
+
const flags = arg && !f.listing ? { ...f, listing: arg } : f;
|
|
317
|
+
return shareCommand(cmd, { origin: s.origin, apiKey: s.apiKey, flags, env, out });
|
|
318
|
+
}
|
|
319
|
+
case "list-agent": case "list-tools": {
|
|
320
|
+
const s = await requireKey(f, env, err);
|
|
321
|
+
const fn = cmd === "list-agent" ? listAgent : listTools;
|
|
322
|
+
await fn({ origin: s.origin, apiKey: s.apiKey, flags: f, env, log: (m) => out(m + "\n") });
|
|
323
|
+
return 0;
|
|
324
|
+
}
|
|
325
|
+
case "jobs": case "sessions": {
|
|
326
|
+
const s = await requireKey(f, env, err);
|
|
327
|
+
const readFile = async (path) => (await readSmallFile(path, 256 * 1024, { requireOwner: false })).toString("utf8");
|
|
328
|
+
const fn = cmd === "jobs" ? jobsCommand : sessionsCommand;
|
|
329
|
+
return fn(arg, arg2, { origin: s.origin, apiKey: s.apiKey, flags: f, out, readFile });
|
|
330
|
+
}
|
|
331
|
+
default:
|
|
332
|
+
throw new Error(`unknown command "${cmd}". Run \`agentcoor --help\`.`);
|
|
333
|
+
}
|
|
334
|
+
}
|
package/src/config.js
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { join, dirname } from "node:path";
|
|
4
|
+
import { ensurePrivateDir, atomicWrite } from "./fsx.js";
|
|
5
|
+
|
|
6
|
+
export const DEFAULT_ORIGIN = "https://agentcoor.nionto.com";
|
|
7
|
+
|
|
8
|
+
export function homeDir(env = process.env) {
|
|
9
|
+
return env.AGENTCOOR_HOME || join(env.HOME || homedir(), ".agentcoor");
|
|
10
|
+
}
|
|
11
|
+
export const configPath = (env = process.env) => join(homeDir(env), "config.json");
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Canonical agent install locations, shared with install.sh:
|
|
15
|
+
* macOS: ~/Library/Application Support/AgentCoor (logs in ~/Library/Logs/AgentCoor)
|
|
16
|
+
* Linux: ${XDG_DATA_HOME:-~/.local/share}/agentcoor (logs in <base>/logs)
|
|
17
|
+
* The CLI's own settings (API key) stay in ~/.agentcoor/config.json.
|
|
18
|
+
*/
|
|
19
|
+
export function installPaths(env = process.env, platform = process.platform) {
|
|
20
|
+
const home = env.HOME || homedir();
|
|
21
|
+
const base = platform === "darwin" ? join(home, "Library", "Application Support", "AgentCoor") : join(env.XDG_DATA_HOME || join(home, ".local", "share"), "agentcoor");
|
|
22
|
+
const logs = platform === "darwin" ? join(home, "Library", "Logs", "AgentCoor") : join(base, "logs");
|
|
23
|
+
return { base, logs, bin: join(base, "bin", "agentcoor-agent"), config: join(base, "agent.json"), state: join(base, "state") };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Normalise an origin: https only (loopback http allowed for local development), no trailing slash or path. */
|
|
27
|
+
export function normalizeOrigin(raw) {
|
|
28
|
+
let u;
|
|
29
|
+
try { u = new URL(String(raw || "").trim()); } catch { throw new Error(`invalid gateway URL: ${raw}`); }
|
|
30
|
+
const loopback = ["localhost", "127.0.0.1", "[::1]"].includes(u.hostname);
|
|
31
|
+
if (u.protocol !== "https:" && !(u.protocol === "http:" && loopback)) throw new Error("gateway URL must use https");
|
|
32
|
+
if (u.username || u.password) throw new Error("gateway URL must not contain credentials");
|
|
33
|
+
return u.origin;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Base URLs each API family expects. Anthropic clients append /v1/messages themselves. */
|
|
37
|
+
export function baseUrls(origin) {
|
|
38
|
+
const o = normalizeOrigin(origin);
|
|
39
|
+
return { origin: o, anthropic: o, openai: `${o}/v1` };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function isApiKey(key) { return typeof key === "string" && /^bbk_[A-Za-z0-9_-]{8,}$/.test(key); }
|
|
43
|
+
|
|
44
|
+
export async function readConfig(env = process.env) {
|
|
45
|
+
try { return JSON.parse(await readFile(configPath(env), "utf8")); }
|
|
46
|
+
catch (e) { if (e.code === "ENOENT") return {}; throw new Error(`cannot read ${configPath(env)}: ${e.message}`); }
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Atomic write with mode 0600 into a private (0700, owned, non-symlink) directory. */
|
|
50
|
+
export async function writePrivateFile(path, content) {
|
|
51
|
+
await ensurePrivateDir(dirname(path));
|
|
52
|
+
await atomicWrite(path, content, { mode: 0o600 });
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export async function writeConfig(cfg, env = process.env) {
|
|
56
|
+
await writePrivateFile(configPath(env), JSON.stringify(cfg, null, 2) + "\n");
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Resolve gateway origin and API key: flags, then env, then config file. */
|
|
60
|
+
export async function resolveSettings(flags = {}, env = process.env) {
|
|
61
|
+
const cfg = await readConfig(env);
|
|
62
|
+
const origin = normalizeOrigin(flags.gateway || env.AGENTCOOR_GATEWAY_URL || cfg.gateway || DEFAULT_ORIGIN);
|
|
63
|
+
const apiKey = flags.key || env.AGENTCOOR_API_KEY || cfg.apiKey || "";
|
|
64
|
+
return { origin, apiKey, cfg };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function maskKey(key) { return key ? `bbk_…${key.slice(-4)}` : "(none)"; }
|