@linkedclaw/cli 0.1.2 → 0.1.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 +248 -48
- package/dist/bin.js +8099 -4778
- package/dist/bin.js.map +1 -1
- package/package.json +17 -32
- package/src/arena/api.ts +154 -0
- package/src/arena/hash.ts +15 -0
- package/src/arena/types.ts +106 -0
- package/src/bin.ts +33 -0
- package/src/commands/agent.ts +264 -0
- package/src/commands/arena.ts +393 -0
- package/src/commands/auth.ts +116 -0
- package/src/commands/converge.ts +969 -0
- package/src/commands/provider.ts +245 -0
- package/src/commands/requester.ts +479 -0
- package/src/config.ts +85 -0
- package/src/context.ts +27 -0
- package/src/converge/api.ts +213 -0
- package/src/converge/hash.ts +35 -0
- package/src/converge/lock.ts +30 -0
- package/src/converge/staging.ts +83 -0
- package/src/converge/types.ts +91 -0
- package/src/converge/workspace.ts +92 -0
- package/src/errors.ts +41 -0
- package/src/handlers/subprocess.ts +185 -0
- package/src/output.ts +57 -0
- package/src/types.ts +90 -0
- package/test/agent-help.test.ts +207 -0
- package/test/arena-api.test.ts +211 -0
- package/test/arena-commands.test.ts +559 -0
- package/test/arena-hash.test.ts +33 -0
- package/test/cli-help.test.ts +82 -0
- package/test/converge-accept.test.ts +206 -0
- package/test/converge-decision.test.ts +274 -0
- package/test/converge-hash.test.ts +58 -0
- package/test/converge-help.test.ts +58 -0
- package/test/converge-lock.test.ts +48 -0
- package/test/converge-review.test.ts +135 -0
- package/test/converge-run.test.ts +286 -0
- package/test/converge-staging.test.ts +161 -0
- package/test/converge-status.test.ts +141 -0
- package/test/converge-workspace.test.ts +92 -0
- package/test/hire-flags.test.ts +55 -0
- package/test/recv-flags.test.ts +83 -0
- package/test/register-browser.test.ts +55 -0
- package/tsconfig.json +14 -0
- package/tsup.config.ts +25 -0
- package/vitest.config.ts +8 -0
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SubprocessHandler bridges the ProviderHandler interface to
|
|
3
|
+
* a user child process via stdin/stdout JSON-lines. Each inbound event gets
|
|
4
|
+
* an `id`; handler must respond with exactly one `{id, result}` or `{id, error}`.
|
|
5
|
+
*
|
|
6
|
+
* Wire protocol (one JSON object per line):
|
|
7
|
+
* CLI → child stdin: {id, type, ...event fields}
|
|
8
|
+
* child stdout → CLI: {id, result: {...}} | {id, error: {code, message}}
|
|
9
|
+
*
|
|
10
|
+
* Anything the child writes to stderr is passed through for operator visibility.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { spawn, type ChildProcess } from "node:child_process";
|
|
14
|
+
import type { Readable, Writable } from "node:stream";
|
|
15
|
+
import { randomUUID } from "node:crypto";
|
|
16
|
+
import { createInterface } from "node:readline";
|
|
17
|
+
import type {
|
|
18
|
+
GigTaskExecuteEvent,
|
|
19
|
+
GigTaskExecuteResult,
|
|
20
|
+
GigTaskOfferDecision,
|
|
21
|
+
GigTaskOfferEvent,
|
|
22
|
+
InvokeEvent,
|
|
23
|
+
InvokeHandlerResult,
|
|
24
|
+
ProviderHandler,
|
|
25
|
+
SessionAcceptDecision,
|
|
26
|
+
SessionCreateEvent,
|
|
27
|
+
SessionEndInboundEvent,
|
|
28
|
+
SessionMessageEvent,
|
|
29
|
+
SessionReply,
|
|
30
|
+
} from "@linkedclaw/provider-runtime";
|
|
31
|
+
|
|
32
|
+
interface Pending {
|
|
33
|
+
resolve: (value: unknown) => void;
|
|
34
|
+
reject: (err: Error) => void;
|
|
35
|
+
timer?: ReturnType<typeof setTimeout>;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface SubprocessHandlerOptions {
|
|
39
|
+
/** Shell-style command. Uses /bin/sh -c on posix, cmd /c on windows. */
|
|
40
|
+
cmd: string;
|
|
41
|
+
/** Working directory for the child. */
|
|
42
|
+
cwd?: string;
|
|
43
|
+
/** Extra env for the child. */
|
|
44
|
+
env?: NodeJS.ProcessEnv;
|
|
45
|
+
/** Per-event default timeout (ms). Core already enforces its own timeouts. */
|
|
46
|
+
requestTimeoutMs?: number;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export class SubprocessHandler implements ProviderHandler {
|
|
50
|
+
private readonly child: ChildProcess;
|
|
51
|
+
private readonly stdin: Writable;
|
|
52
|
+
private readonly pending = new Map<string, Pending>();
|
|
53
|
+
private readonly requestTimeoutMs: number;
|
|
54
|
+
|
|
55
|
+
constructor(opts: SubprocessHandlerOptions) {
|
|
56
|
+
this.requestTimeoutMs = opts.requestTimeoutMs ?? 600_000;
|
|
57
|
+
const shell = process.platform === "win32" ? "cmd.exe" : "/bin/sh";
|
|
58
|
+
const shellArgs = process.platform === "win32" ? ["/c", opts.cmd] : ["-c", opts.cmd];
|
|
59
|
+
this.child = spawn(shell, shellArgs, {
|
|
60
|
+
stdio: ["pipe", "pipe", "inherit"],
|
|
61
|
+
cwd: opts.cwd,
|
|
62
|
+
env: { ...process.env, ...opts.env },
|
|
63
|
+
});
|
|
64
|
+
if (!this.child.stdin || !this.child.stdout) {
|
|
65
|
+
throw new Error("subprocess handler: child stdin/stdout not available");
|
|
66
|
+
}
|
|
67
|
+
this.stdin = this.child.stdin;
|
|
68
|
+
const rl = createInterface({ input: this.child.stdout as Readable });
|
|
69
|
+
rl.on("line", (line) => this.handleLine(line));
|
|
70
|
+
this.child.on("exit", (code, signal) => this.handleExit(code, signal));
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// ───── ProviderHandler impl ─────
|
|
74
|
+
|
|
75
|
+
async onSessionCreate(evt: SessionCreateEvent): Promise<SessionAcceptDecision> {
|
|
76
|
+
return this.request<SessionAcceptDecision>(evt);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async onSessionMessage(evt: SessionMessageEvent): Promise<SessionReply> {
|
|
80
|
+
const r = await this.request<unknown>(evt);
|
|
81
|
+
if (r === null || r === undefined) return null;
|
|
82
|
+
if (typeof r === "string") return r;
|
|
83
|
+
if (typeof r === "object") {
|
|
84
|
+
const obj = r as Record<string, unknown>;
|
|
85
|
+
if (typeof obj["content"] === "string") return obj["content"] as string;
|
|
86
|
+
if ("payload" in obj) return obj as { payload: unknown };
|
|
87
|
+
}
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async onSessionEnd(evt: SessionEndInboundEvent): Promise<void> {
|
|
92
|
+
try {
|
|
93
|
+
await this.request<unknown>(evt);
|
|
94
|
+
} catch {
|
|
95
|
+
// session is ending anyway; ignore handler errors here
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async onInvoke(evt: InvokeEvent): Promise<InvokeHandlerResult> {
|
|
100
|
+
return this.request<InvokeHandlerResult>(evt);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async onGigTaskOffer(evt: GigTaskOfferEvent): Promise<GigTaskOfferDecision> {
|
|
104
|
+
return this.request<GigTaskOfferDecision>(evt);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async onGigTaskExecute(evt: GigTaskExecuteEvent): Promise<GigTaskExecuteResult> {
|
|
108
|
+
return this.request<GigTaskExecuteResult>(evt);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// ───── shutdown ─────
|
|
112
|
+
|
|
113
|
+
async close(): Promise<void> {
|
|
114
|
+
if (this.child.exitCode !== null) return;
|
|
115
|
+
this.stdin.end();
|
|
116
|
+
try {
|
|
117
|
+
this.child.kill("SIGTERM");
|
|
118
|
+
} catch {
|
|
119
|
+
// already gone
|
|
120
|
+
}
|
|
121
|
+
for (const p of this.pending.values()) {
|
|
122
|
+
p.reject(new Error("handler_closed"));
|
|
123
|
+
if (p.timer) clearTimeout(p.timer);
|
|
124
|
+
}
|
|
125
|
+
this.pending.clear();
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// ───── internals ─────
|
|
129
|
+
|
|
130
|
+
private request<T>(frame: { type: string } & object): Promise<T> {
|
|
131
|
+
const id = randomUUID();
|
|
132
|
+
const line = JSON.stringify({ id, ...frame }) + "\n";
|
|
133
|
+
return new Promise<T>((resolve, reject) => {
|
|
134
|
+
const timer = setTimeout(() => {
|
|
135
|
+
this.pending.delete(id);
|
|
136
|
+
reject(new Error(`handler_timeout: no response for ${frame.type} after ${this.requestTimeoutMs}ms`));
|
|
137
|
+
}, this.requestTimeoutMs);
|
|
138
|
+
if (typeof (timer as unknown as { unref?: () => void }).unref === "function") {
|
|
139
|
+
(timer as unknown as { unref: () => void }).unref();
|
|
140
|
+
}
|
|
141
|
+
this.pending.set(id, { resolve: resolve as (v: unknown) => void, reject, timer });
|
|
142
|
+
this.stdin.write(line, (err) => {
|
|
143
|
+
if (err) {
|
|
144
|
+
this.pending.delete(id);
|
|
145
|
+
clearTimeout(timer);
|
|
146
|
+
reject(err);
|
|
147
|
+
}
|
|
148
|
+
});
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
private handleLine(line: string): void {
|
|
153
|
+
const trimmed = line.trim();
|
|
154
|
+
if (!trimmed) return;
|
|
155
|
+
let msg: unknown;
|
|
156
|
+
try {
|
|
157
|
+
msg = JSON.parse(trimmed);
|
|
158
|
+
} catch {
|
|
159
|
+
process.stderr.write(`handler wrote non-JSON line: ${trimmed}\n`);
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
if (typeof msg !== "object" || msg === null) return;
|
|
163
|
+
const m = msg as { id?: unknown; result?: unknown; error?: unknown };
|
|
164
|
+
if (typeof m.id !== "string") return;
|
|
165
|
+
const p = this.pending.get(m.id);
|
|
166
|
+
if (!p) return;
|
|
167
|
+
this.pending.delete(m.id);
|
|
168
|
+
if (p.timer) clearTimeout(p.timer);
|
|
169
|
+
if (m.error !== undefined) {
|
|
170
|
+
const err = m.error as { code?: string; message?: string };
|
|
171
|
+
p.reject(Object.assign(new Error(err.message ?? "handler_error"), { code: err.code }));
|
|
172
|
+
} else {
|
|
173
|
+
p.resolve(m.result);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
private handleExit(code: number | null, signal: NodeJS.Signals | null): void {
|
|
178
|
+
const reason = signal ?? `exit ${code}`;
|
|
179
|
+
for (const p of this.pending.values()) {
|
|
180
|
+
p.reject(new Error(`handler_crashed (${reason})`));
|
|
181
|
+
if (p.timer) clearTimeout(p.timer);
|
|
182
|
+
}
|
|
183
|
+
this.pending.clear();
|
|
184
|
+
}
|
|
185
|
+
}
|
package/src/output.ts
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { ApiError, LinkedClawError, NetworkError } from "./errors.js";
|
|
2
|
+
|
|
3
|
+
export interface PrintOptions {
|
|
4
|
+
human?: boolean;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function printResult(value: unknown, opts: PrintOptions = {}): void {
|
|
8
|
+
if (opts.human) {
|
|
9
|
+
if (typeof value === "string") process.stdout.write(value + "\n");
|
|
10
|
+
else process.stdout.write(JSON.stringify(value, null, 2) + "\n");
|
|
11
|
+
return;
|
|
12
|
+
}
|
|
13
|
+
process.stdout.write(JSON.stringify(value) + "\n");
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function printError(err: unknown): void {
|
|
17
|
+
const payload: Record<string, unknown> =
|
|
18
|
+
err instanceof ApiError
|
|
19
|
+
? { error: err.code, status: err.status, path: err.path, detail: err.detail }
|
|
20
|
+
: err instanceof NetworkError
|
|
21
|
+
? { error: err.code, message: err.message }
|
|
22
|
+
: err instanceof LinkedClawError
|
|
23
|
+
? { error: err.code, message: err.message }
|
|
24
|
+
: { error: "internal_error", message: err instanceof Error ? err.message : String(err) };
|
|
25
|
+
process.stderr.write(JSON.stringify(payload) + "\n");
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export async function runCommand(fn: () => Promise<unknown> | unknown, opts: PrintOptions = {}): Promise<void> {
|
|
29
|
+
try {
|
|
30
|
+
const out = await fn();
|
|
31
|
+
if (out !== undefined) printResult(out, opts);
|
|
32
|
+
} catch (err) {
|
|
33
|
+
printError(err);
|
|
34
|
+
process.exitCode = 1;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export async function readLine(prompt: string): Promise<string> {
|
|
39
|
+
const { createInterface } = await import("node:readline/promises");
|
|
40
|
+
const rl = createInterface({ input: process.stdin, output: process.stderr });
|
|
41
|
+
try {
|
|
42
|
+
return (await rl.question(prompt)).trim();
|
|
43
|
+
} finally {
|
|
44
|
+
rl.close();
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export async function readStdin(): Promise<string> {
|
|
49
|
+
if (process.stdin.isTTY) {
|
|
50
|
+
process.stderr.write("reading from stdin — press Ctrl-D when done\n");
|
|
51
|
+
}
|
|
52
|
+
const chunks: Buffer[] = [];
|
|
53
|
+
for await (const chunk of process.stdin) {
|
|
54
|
+
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
|
55
|
+
}
|
|
56
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
57
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
// CLI-local request/response types. Only types consumed by commands are here.
|
|
2
|
+
// Response/listing types are typed as Record<string,unknown> in the SDK methods;
|
|
3
|
+
// these interfaces are used for request body construction and CLI flag parsing.
|
|
4
|
+
|
|
5
|
+
export type EntityId = string;
|
|
6
|
+
|
|
7
|
+
export type PricingModel =
|
|
8
|
+
| "free"
|
|
9
|
+
| "per_session"
|
|
10
|
+
| "per_message"
|
|
11
|
+
| "per_call"
|
|
12
|
+
| "per_task";
|
|
13
|
+
|
|
14
|
+
export type VerifyMethod = "none" | "custom" | "output_schema";
|
|
15
|
+
|
|
16
|
+
export interface CreateAgentRequest {
|
|
17
|
+
slug: string;
|
|
18
|
+
name: string;
|
|
19
|
+
description: string;
|
|
20
|
+
capabilities: string[];
|
|
21
|
+
pricing_model?: PricingModel;
|
|
22
|
+
price_credits?: number;
|
|
23
|
+
status?: "online" | "offline" | "disabled";
|
|
24
|
+
verify_method?: VerifyMethod;
|
|
25
|
+
verify_config?: Record<string, unknown>;
|
|
26
|
+
min_requester_reputation?: number;
|
|
27
|
+
min_sessions?: number;
|
|
28
|
+
require_payment?: boolean;
|
|
29
|
+
slot_fulfillment?: string[];
|
|
30
|
+
fork_policy?: string | null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export type UpdateAgentRequest = Partial<Omit<CreateAgentRequest, "slug">>;
|
|
34
|
+
|
|
35
|
+
export interface EndSessionRequest {
|
|
36
|
+
message_count?: number | null;
|
|
37
|
+
final_output?: string | null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface InvokeRequest {
|
|
41
|
+
capability: string;
|
|
42
|
+
input: Record<string, unknown>;
|
|
43
|
+
max_credits?: number;
|
|
44
|
+
manifest_id?: EntityId;
|
|
45
|
+
manifest?: TaskManifest;
|
|
46
|
+
timeout_seconds?: number;
|
|
47
|
+
referred_by?: EntityId;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface TaskManifest {
|
|
51
|
+
intention: string;
|
|
52
|
+
[key: string]: unknown;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface CreateGigTaskRequest {
|
|
56
|
+
capability: string;
|
|
57
|
+
instruction: string;
|
|
58
|
+
target_providers: number;
|
|
59
|
+
credits_per_provider: number;
|
|
60
|
+
deadline?: string | null;
|
|
61
|
+
task_params?: Record<string, unknown>;
|
|
62
|
+
result_schema?: Record<string, unknown> | null;
|
|
63
|
+
acceptance_criteria?: Record<string, unknown> | null;
|
|
64
|
+
partition_type?: string;
|
|
65
|
+
payment_type?: string;
|
|
66
|
+
required_stake?: number;
|
|
67
|
+
referred_by?: EntityId;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export interface AcceptGigTaskRequest {
|
|
71
|
+
agent_id: EntityId;
|
|
72
|
+
slot_key?: string | null;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export interface GigTaskSubmitRequest {
|
|
76
|
+
result_data: string;
|
|
77
|
+
result_payload?: Record<string, unknown> | null;
|
|
78
|
+
proof?: Array<{ type: string; value: string; label?: string }> | null;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Re-export provider-runtime types needed by commands
|
|
82
|
+
export type {
|
|
83
|
+
ProviderHandler,
|
|
84
|
+
GigTaskOfferDecision,
|
|
85
|
+
GigTaskExecuteResult,
|
|
86
|
+
InvokeHandlerResult,
|
|
87
|
+
SessionAcceptDecision,
|
|
88
|
+
SessionReply,
|
|
89
|
+
} from "@linkedclaw/provider-runtime";
|
|
90
|
+
export type { MessageType } from "@linkedclaw/provider";
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
import { describe, it, expect, beforeAll, beforeEach, afterEach, vi } from "vitest";
|
|
2
|
+
import { spawnSync } from "node:child_process";
|
|
3
|
+
import { EventEmitter } from "node:events";
|
|
4
|
+
import { chmodSync, existsSync, mkdtempSync, writeFileSync } from "node:fs";
|
|
5
|
+
import { tmpdir } from "node:os";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
import { fileURLToPath } from "node:url";
|
|
8
|
+
import { Command } from "commander";
|
|
9
|
+
|
|
10
|
+
const spawnMock = vi.hoisted(() => vi.fn());
|
|
11
|
+
|
|
12
|
+
vi.mock("node:child_process", async (importOriginal) => {
|
|
13
|
+
const actual = await importOriginal<typeof import("node:child_process")>();
|
|
14
|
+
return { ...actual, spawn: spawnMock };
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
18
|
+
const BIN = path.resolve(__dirname, "../dist/bin.js");
|
|
19
|
+
const ORIGINAL_CONFIG_ENV = process.env.LINKEDCLAW_OWNER_AGENT_CONFIG;
|
|
20
|
+
const ORIGINAL_EXIT_CODE = process.exitCode;
|
|
21
|
+
|
|
22
|
+
type FakeChild = EventEmitter & {
|
|
23
|
+
killed: boolean;
|
|
24
|
+
kill: ReturnType<typeof vi.fn>;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
let activeChild: FakeChild | undefined;
|
|
28
|
+
|
|
29
|
+
beforeAll(() => {
|
|
30
|
+
if (!existsSync(BIN)) {
|
|
31
|
+
throw new Error(`bin not built — run 'pnpm --filter @linkedclaw/cli build' first (${BIN})`);
|
|
32
|
+
}
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
beforeEach(() => {
|
|
36
|
+
spawnMock.mockReset();
|
|
37
|
+
delete process.env.LINKEDCLAW_OWNER_AGENT_CONFIG;
|
|
38
|
+
activeChild = undefined;
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
afterEach(() => {
|
|
42
|
+
if (activeChild !== undefined) {
|
|
43
|
+
activeChild.emit("exit", 0, null);
|
|
44
|
+
activeChild = undefined;
|
|
45
|
+
}
|
|
46
|
+
if (ORIGINAL_CONFIG_ENV === undefined) {
|
|
47
|
+
delete process.env.LINKEDCLAW_OWNER_AGENT_CONFIG;
|
|
48
|
+
} else {
|
|
49
|
+
process.env.LINKEDCLAW_OWNER_AGENT_CONFIG = ORIGINAL_CONFIG_ENV;
|
|
50
|
+
}
|
|
51
|
+
process.exitCode = ORIGINAL_EXIT_CODE;
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
function run(args: string[]): { code: number | null; stdout: string; stderr: string } {
|
|
55
|
+
const r = spawnSync("node", [BIN, ...args], { encoding: "utf8" });
|
|
56
|
+
return { code: r.status, stdout: r.stdout ?? "", stderr: r.stderr ?? "" };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function makeFakeChild(): FakeChild {
|
|
60
|
+
const child = new EventEmitter() as FakeChild;
|
|
61
|
+
child.killed = false;
|
|
62
|
+
child.kill = vi.fn(() => true);
|
|
63
|
+
activeChild = child;
|
|
64
|
+
return child;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function makeFakePython(): string {
|
|
68
|
+
const dir = mkdtempSync(path.join(tmpdir(), "linkedclaw-agent-cli-"));
|
|
69
|
+
const fakePython = path.join(dir, "fake-python");
|
|
70
|
+
writeFileSync(fakePython, "#!/bin/sh\nexit 0\n", "utf8");
|
|
71
|
+
chmodSync(fakePython, 0o700);
|
|
72
|
+
return fakePython;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async function runHiddenAgent(args: string[]): Promise<FakeChild> {
|
|
76
|
+
const child = makeFakeChild();
|
|
77
|
+
spawnMock.mockReturnValueOnce(child);
|
|
78
|
+
const program = new Command();
|
|
79
|
+
program.exitOverride();
|
|
80
|
+
const { registerAgentCommands } = await import("../src/commands/agent.js");
|
|
81
|
+
registerAgentCommands(program);
|
|
82
|
+
await program.parseAsync(["node", "cli", "agent", "run", ...args]);
|
|
83
|
+
return child;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async function runHiddenRotate(args: string[]): Promise<FakeChild | undefined> {
|
|
87
|
+
const child = makeFakeChild();
|
|
88
|
+
spawnMock.mockReturnValueOnce(child);
|
|
89
|
+
const program = new Command();
|
|
90
|
+
program.exitOverride();
|
|
91
|
+
const { registerAgentCommands } = await import("../src/commands/agent.js");
|
|
92
|
+
registerAgentCommands(program);
|
|
93
|
+
await program.parseAsync(["node", "cli", "agent", "rotate-mandate", ...args]);
|
|
94
|
+
return process.exitCode === 1 ? undefined : child;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
describe("agent command packaging", () => {
|
|
98
|
+
it("exposes the owner-agent run command in the npm CLI", () => {
|
|
99
|
+
const top = run(["--help"]);
|
|
100
|
+
expect(top.code).toBe(0);
|
|
101
|
+
expect(top.stdout).toContain("agent");
|
|
102
|
+
|
|
103
|
+
const agent = run(["agent", "--help"]);
|
|
104
|
+
expect(agent.code).toBe(0);
|
|
105
|
+
expect(agent.stdout).toContain("run");
|
|
106
|
+
expect(agent.stdout).toContain("rotate-mandate");
|
|
107
|
+
|
|
108
|
+
const runHelp = run(["agent", "run", "--help"]);
|
|
109
|
+
expect(runHelp.code).toBe(0);
|
|
110
|
+
expect(runHelp.stdout).toContain("--config");
|
|
111
|
+
expect(runHelp.stdout).toContain("--watch");
|
|
112
|
+
expect(runHelp.stdout).toContain("--once");
|
|
113
|
+
expect(runHelp.stdout).toContain("--python-command");
|
|
114
|
+
|
|
115
|
+
const rotateHelp = run(["agent", "rotate-mandate", "--help"]);
|
|
116
|
+
expect(rotateHelp.code).toBe(0);
|
|
117
|
+
expect(rotateHelp.stdout).toContain("--config");
|
|
118
|
+
expect(rotateHelp.stdout).toContain("--old-mandate-id");
|
|
119
|
+
expect(rotateHelp.stdout).toContain("--expires-at");
|
|
120
|
+
expect(rotateHelp.stdout).toContain("--python-command");
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("reads LINKEDCLAW_OWNER_AGENT_CONFIG when --config is omitted", async () => {
|
|
124
|
+
const configPath = path.join(mkdtempSync(path.join(tmpdir(), "linkedclaw-agent-cli-")), "config.yaml");
|
|
125
|
+
const fakePython = makeFakePython();
|
|
126
|
+
writeFileSync(configPath, "agent_id: ag_owner\n", "utf8");
|
|
127
|
+
process.env.LINKEDCLAW_OWNER_AGENT_CONFIG = configPath;
|
|
128
|
+
|
|
129
|
+
await runHiddenAgent(["--watch", "debate_1:clg_1", "--once", "--python-command", fakePython]);
|
|
130
|
+
|
|
131
|
+
expect(spawnMock).toHaveBeenCalledWith(fakePython, [
|
|
132
|
+
"-m",
|
|
133
|
+
"linkedclaw.owner_agent.cli",
|
|
134
|
+
"run",
|
|
135
|
+
"--config",
|
|
136
|
+
configPath,
|
|
137
|
+
"--watch",
|
|
138
|
+
"debate_1:clg_1",
|
|
139
|
+
"--once",
|
|
140
|
+
], { stdio: "inherit" });
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it("forwards SIGTERM and SIGINT to the Python child", async () => {
|
|
144
|
+
const fakePython = makeFakePython();
|
|
145
|
+
const sigtermChild = await runHiddenAgent(["--once", "--python-command", fakePython]);
|
|
146
|
+
|
|
147
|
+
process.emit("SIGTERM");
|
|
148
|
+
expect(sigtermChild.kill).toHaveBeenCalledWith("SIGTERM");
|
|
149
|
+
sigtermChild.emit("exit", 0, null);
|
|
150
|
+
activeChild = undefined;
|
|
151
|
+
|
|
152
|
+
const sigintChild = await runHiddenAgent(["--once", "--python-command", fakePython]);
|
|
153
|
+
process.emit("SIGINT");
|
|
154
|
+
expect(sigintChild.kill).toHaveBeenCalledWith("SIGINT");
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it("rotates mandates with env config and forwards optional flags", async () => {
|
|
158
|
+
const configPath = path.join(mkdtempSync(path.join(tmpdir(), "linkedclaw-agent-cli-")), "config.yaml");
|
|
159
|
+
const fakePython = makeFakePython();
|
|
160
|
+
writeFileSync(configPath, "agent_id: ag_owner\n", "utf8");
|
|
161
|
+
process.env.LINKEDCLAW_OWNER_AGENT_CONFIG = configPath;
|
|
162
|
+
|
|
163
|
+
await runHiddenRotate([
|
|
164
|
+
"--old-mandate-id",
|
|
165
|
+
"man_old",
|
|
166
|
+
"--expires-at",
|
|
167
|
+
"2026-05-04T00:00:00Z",
|
|
168
|
+
"--python-command",
|
|
169
|
+
fakePython,
|
|
170
|
+
]);
|
|
171
|
+
|
|
172
|
+
expect(spawnMock).toHaveBeenCalledWith(fakePython, [
|
|
173
|
+
"-m",
|
|
174
|
+
"linkedclaw.owner_agent.cli",
|
|
175
|
+
"rotate-mandate",
|
|
176
|
+
"--config",
|
|
177
|
+
configPath,
|
|
178
|
+
"--old-mandate-id",
|
|
179
|
+
"man_old",
|
|
180
|
+
"--expires-at",
|
|
181
|
+
"2026-05-04T00:00:00Z",
|
|
182
|
+
], { stdio: "inherit" });
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it("rejects invalid rotate mandate options as JSON", async () => {
|
|
186
|
+
const configPath = path.join(mkdtempSync(path.join(tmpdir(), "linkedclaw-agent-cli-")), "config.yaml");
|
|
187
|
+
const fakePython = makeFakePython();
|
|
188
|
+
writeFileSync(configPath, "agent_id: ag_owner\n", "utf8");
|
|
189
|
+
const stderrSpy = vi.spyOn(process.stderr, "write").mockImplementation(() => true);
|
|
190
|
+
|
|
191
|
+
await runHiddenRotate([
|
|
192
|
+
"--config",
|
|
193
|
+
configPath,
|
|
194
|
+
"--old-mandate-id",
|
|
195
|
+
"",
|
|
196
|
+
"--python-command",
|
|
197
|
+
fakePython,
|
|
198
|
+
]);
|
|
199
|
+
|
|
200
|
+
expect(spawnMock).not.toHaveBeenCalled();
|
|
201
|
+
expect(stderrSpy).toHaveBeenCalled();
|
|
202
|
+
const payload = JSON.parse(String(stderrSpy.mock.calls[0][0]));
|
|
203
|
+
expect(payload.error).toBe("invalid_agent_rotate_mandate_option");
|
|
204
|
+
expect(payload.message).toContain("--old-mandate-id");
|
|
205
|
+
stderrSpy.mockRestore();
|
|
206
|
+
});
|
|
207
|
+
});
|