petbox-wire 0.1.0-ci.1206
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 +119 -0
- package/bin/petbox-wire.js +30 -0
- package/package.json +44 -0
- package/src/agent-def-fetch.test.ts +317 -0
- package/src/agent-def-fetch.ts +409 -0
- package/src/agent-definition.ts +210 -0
- package/src/append-meta.test.ts +93 -0
- package/src/append.ts +172 -0
- package/src/apply-artifacts.ts +337 -0
- package/src/apply-root.test.ts +148 -0
- package/src/apply-root.ts +68 -0
- package/src/apply-write.test.ts +169 -0
- package/src/apply-write.ts +84 -0
- package/src/canon.ts +140 -0
- package/src/doctor-definition.test.ts +128 -0
- package/src/droid-pull-memory.ts +126 -0
- package/src/droid-push-session.ts +100 -0
- package/src/droid-transcript.ts +47 -0
- package/src/harness-capabilities.ts +126 -0
- package/src/harness-models.ts +158 -0
- package/src/hook-drain.ts +42 -0
- package/src/hook-prune.test.ts +165 -0
- package/src/hook-prune.ts +83 -0
- package/src/import-sessions.ts +261 -0
- package/src/opencode-plugin.ts +127 -0
- package/src/origin-marker.ts +37 -0
- package/src/posix-env.test.ts +99 -0
- package/src/posix-env.ts +61 -0
- package/src/protocol.test.ts +236 -0
- package/src/protocol.ts +136 -0
- package/src/pull-memory.test.ts +168 -0
- package/src/pull-memory.ts +119 -0
- package/src/push-session.ts +99 -0
- package/src/registry.ts +120 -0
- package/src/roles.test.ts +255 -0
- package/src/roles.ts +241 -0
- package/src/self-smoke.test.ts +103 -0
- package/src/self-smoke.ts +96 -0
- package/src/telemetry-settings.test.ts +65 -0
- package/src/telemetry-settings.ts +55 -0
- package/src/templates/SKILL.md +45 -0
- package/src/templates/agent-factory/SKILL.md +56 -0
- package/src/transcript.ts +86 -0
- package/src/truthfulness.test.ts +544 -0
- package/src/truthfulness.ts +164 -0
- package/src/wire-exit.test.ts +43 -0
- package/src/wire-exit.ts +25 -0
- package/src/wire-identity.test.ts +65 -0
- package/src/wire-identity.ts +64 -0
- package/src/wire.ts +1384 -0
package/src/roles.ts
ADDED
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
// Local role→model binding store (~/.petbox/roles.json).
|
|
2
|
+
//
|
|
3
|
+
// Spec (role-model-binding-local, binding-not-server-authoritative):
|
|
4
|
+
// - Active profile + per-agent role→model bindings live on the machine (owner axis = $HOME).
|
|
5
|
+
// - Server may observe a stamp as session metadata later, but is NEVER the source of truth.
|
|
6
|
+
// - All load/save/resolve paths are offline (no fetch).
|
|
7
|
+
//
|
|
8
|
+
// Plain TS for native node type-stripping: zero deps. Home is injectable so tests never touch
|
|
9
|
+
// the real ~/.petbox.
|
|
10
|
+
|
|
11
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
12
|
+
import { homedir } from "node:os";
|
|
13
|
+
import { dirname, join } from "node:path";
|
|
14
|
+
|
|
15
|
+
export type RoleBinding = {
|
|
16
|
+
readonly model: string;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export type AgentRoles = {
|
|
20
|
+
readonly roles: Readonly<Record<string, RoleBinding>>;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export type Profile = {
|
|
24
|
+
readonly agents: Readonly<Record<string, AgentRoles>>;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export type RolesFile = {
|
|
28
|
+
readonly activeProfile: string;
|
|
29
|
+
readonly profiles: Readonly<Record<string, Profile>>;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
/** Best-effort observation stamp for a session push (client-side only until the server accepts it). */
|
|
33
|
+
export type ObservedBinding = {
|
|
34
|
+
readonly profile: string;
|
|
35
|
+
readonly agent: string;
|
|
36
|
+
readonly roles: Readonly<Record<string, string>>;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const EMPTY: RolesFile = { activeProfile: "default", profiles: {} };
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Canonical agent ids used by session push / harness matrix (`droid-push-session` stamps
|
|
43
|
+
* agent:"droid", HARNESS_IDS uses droid).
|
|
44
|
+
* Aliases accepted in roles.json for the same bucket — lookup is alias-aware so a file
|
|
45
|
+
* written as `factory-droid` still resolves when push asks for `droid`.
|
|
46
|
+
*/
|
|
47
|
+
export const CANONICAL_AGENT_IDS = ["claude-code", "opencode", "droid"] as const;
|
|
48
|
+
|
|
49
|
+
const AGENT_ALIASES: Readonly<Record<string, string>> = {
|
|
50
|
+
"factory-droid": "droid",
|
|
51
|
+
factory: "droid",
|
|
52
|
+
cc: "claude-code",
|
|
53
|
+
claude: "claude-code",
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
/** Map any alias / known id to the canonical agent id; unknown strings pass through. */
|
|
57
|
+
export function canonicalAgentId(agent: string): string {
|
|
58
|
+
const a = agent.trim();
|
|
59
|
+
if (!a) return a;
|
|
60
|
+
return AGENT_ALIASES[a] ?? a;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Keys to try when reading agents{} from roles.json for a requested agent id. */
|
|
64
|
+
function agentLookupKeys(agent: string): readonly string[] {
|
|
65
|
+
const canon = canonicalAgentId(agent);
|
|
66
|
+
const keys = new Set<string>([agent, canon]);
|
|
67
|
+
for (const [alias, c] of Object.entries(AGENT_ALIASES)) {
|
|
68
|
+
if (c === canon) keys.add(alias);
|
|
69
|
+
}
|
|
70
|
+
return [...keys];
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function rolesPath(homeDir: string = homedir()): string {
|
|
74
|
+
return join(homeDir, ".petbox", "roles.json");
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function isPlainObject(v: unknown): v is Record<string, unknown> {
|
|
78
|
+
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function asModelBinding(v: unknown): RoleBinding | null {
|
|
82
|
+
if (!isPlainObject(v)) return null;
|
|
83
|
+
const model = v["model"];
|
|
84
|
+
if (typeof model !== "string" || !model.trim()) return null;
|
|
85
|
+
return { model: model.trim() };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function asAgentRoles(v: unknown): AgentRoles | null {
|
|
89
|
+
if (!isPlainObject(v)) return null;
|
|
90
|
+
const rolesRaw = v["roles"];
|
|
91
|
+
if (!isPlainObject(rolesRaw)) return { roles: {} };
|
|
92
|
+
const roles: Record<string, RoleBinding> = {};
|
|
93
|
+
for (const [role, binding] of Object.entries(rolesRaw)) {
|
|
94
|
+
const b = asModelBinding(binding);
|
|
95
|
+
if (b) roles[role] = b;
|
|
96
|
+
}
|
|
97
|
+
return { roles };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function asProfile(v: unknown): Profile {
|
|
101
|
+
if (!isPlainObject(v)) return { agents: {} };
|
|
102
|
+
const agentsRaw = v["agents"];
|
|
103
|
+
if (!isPlainObject(agentsRaw)) return { agents: {} };
|
|
104
|
+
const agents: Record<string, AgentRoles> = {};
|
|
105
|
+
for (const [agent, ar] of Object.entries(agentsRaw)) {
|
|
106
|
+
const parsed = asAgentRoles(ar);
|
|
107
|
+
if (parsed) agents[agent] = parsed;
|
|
108
|
+
}
|
|
109
|
+
return { agents };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Light validation: coerce unknown JSON into a RolesFile; drop junk fields. */
|
|
113
|
+
export function normalizeRoles(raw: unknown): RolesFile {
|
|
114
|
+
if (!isPlainObject(raw)) return { ...EMPTY };
|
|
115
|
+
const activeProfile =
|
|
116
|
+
typeof raw["activeProfile"] === "string" && raw["activeProfile"].trim()
|
|
117
|
+
? raw["activeProfile"].trim()
|
|
118
|
+
: "default";
|
|
119
|
+
const profilesRaw = raw["profiles"];
|
|
120
|
+
if (!isPlainObject(profilesRaw)) return { activeProfile, profiles: {} };
|
|
121
|
+
const profiles: Record<string, Profile> = {};
|
|
122
|
+
for (const [name, p] of Object.entries(profilesRaw)) {
|
|
123
|
+
if (!name.trim()) continue;
|
|
124
|
+
profiles[name] = asProfile(p);
|
|
125
|
+
}
|
|
126
|
+
return { activeProfile, profiles };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Load ~/.petbox/roles.json. Never throws: missing/unreadable/invalid → empty shell
|
|
131
|
+
* ({ activeProfile: "default", profiles: {} }).
|
|
132
|
+
*/
|
|
133
|
+
export function loadRoles(homeDir: string = homedir()): RolesFile {
|
|
134
|
+
const path = rolesPath(homeDir);
|
|
135
|
+
try {
|
|
136
|
+
if (!existsSync(path)) return { ...EMPTY, profiles: {} };
|
|
137
|
+
const raw = JSON.parse(readFileSync(path, "utf8"));
|
|
138
|
+
return normalizeRoles(raw);
|
|
139
|
+
} catch {
|
|
140
|
+
return { ...EMPTY, profiles: {} };
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Persist roles.json (creates ~/.petbox if needed). */
|
|
145
|
+
export function saveRoles(data: RolesFile, homeDir: string = homedir()): void {
|
|
146
|
+
const path = rolesPath(homeDir);
|
|
147
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
148
|
+
const normalized = normalizeRoles(data);
|
|
149
|
+
writeFileSync(path, JSON.stringify(normalized, null, 2) + "\n", "utf8");
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** True when there is no active profile shell and no agent role bindings at all. */
|
|
153
|
+
export function isEmptyRoles(data: RolesFile): boolean {
|
|
154
|
+
const names = Object.keys(data.profiles);
|
|
155
|
+
if (names.length === 0) return true;
|
|
156
|
+
for (const p of Object.values(data.profiles)) {
|
|
157
|
+
for (const a of Object.values(p.agents)) {
|
|
158
|
+
if (Object.keys(a.roles).length > 0) return false;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
// Profiles may exist as empty shells (after `profile use`) — still "empty" for display
|
|
162
|
+
// of bindings, but we still surface the active profile name.
|
|
163
|
+
return Object.values(data.profiles).every((p) => Object.keys(p.agents).length === 0);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Set activeProfile; create an empty profile shell if the name is new.
|
|
168
|
+
* Returns the updated file (caller should saveRoles).
|
|
169
|
+
*/
|
|
170
|
+
export function useProfile(data: RolesFile, name: string): RolesFile {
|
|
171
|
+
const n = name.trim();
|
|
172
|
+
if (!n) throw new Error("profile name must be non-empty");
|
|
173
|
+
const profiles: Record<string, Profile> = { ...data.profiles };
|
|
174
|
+
if (!profiles[n]) profiles[n] = { agents: {} };
|
|
175
|
+
return { activeProfile: n, profiles };
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** Role→model map for one agent under the active profile (missing → {}). Alias-aware. */
|
|
179
|
+
export function resolveAgentRoles(
|
|
180
|
+
data: RolesFile,
|
|
181
|
+
agent: string,
|
|
182
|
+
): Readonly<Record<string, string>> {
|
|
183
|
+
const profile = data.profiles[data.activeProfile];
|
|
184
|
+
if (!profile) return {};
|
|
185
|
+
// Prefer exact key, then aliases / canonical (first non-empty wins).
|
|
186
|
+
for (const key of agentLookupKeys(agent)) {
|
|
187
|
+
const ar = profile.agents[key];
|
|
188
|
+
if (!ar) continue;
|
|
189
|
+
const out: Record<string, string> = {};
|
|
190
|
+
for (const [role, b] of Object.entries(ar.roles)) out[role] = b.model;
|
|
191
|
+
if (Object.keys(out).length > 0) return out;
|
|
192
|
+
}
|
|
193
|
+
return {};
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Pure client helper: observed binding stamp for session metadata.
|
|
198
|
+
* Returns null when the active profile has no roles for this agent (do not invent defaults).
|
|
199
|
+
* `agent` on the stamp is always the **canonical** id (e.g. droid, not factory-droid).
|
|
200
|
+
*/
|
|
201
|
+
export function resolveObservedBinding(
|
|
202
|
+
agent: string,
|
|
203
|
+
homeDir: string = homedir(),
|
|
204
|
+
): ObservedBinding | null {
|
|
205
|
+
const data = loadRoles(homeDir);
|
|
206
|
+
const roles = resolveAgentRoles(data, agent);
|
|
207
|
+
if (Object.keys(roles).length === 0) return null;
|
|
208
|
+
return {
|
|
209
|
+
profile: data.activeProfile,
|
|
210
|
+
agent: canonicalAgentId(agent),
|
|
211
|
+
roles,
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** Bootstrap-safe export shape (no secrets — roles.json has none). */
|
|
216
|
+
export function exportRolesBootstrap(data: RolesFile): RolesFile {
|
|
217
|
+
return normalizeRoles(data);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** Human-readable dump of the active profile's agent/role/model tree. */
|
|
221
|
+
export function formatResolvedBinding(data: RolesFile): string {
|
|
222
|
+
const lines: string[] = [];
|
|
223
|
+
lines.push(`activeProfile: ${data.activeProfile}`);
|
|
224
|
+
const profile = data.profiles[data.activeProfile];
|
|
225
|
+
if (!profile || Object.keys(profile.agents).length === 0) {
|
|
226
|
+
lines.push("(no agent role bindings for this profile)");
|
|
227
|
+
return lines.join("\n");
|
|
228
|
+
}
|
|
229
|
+
for (const [agent, ar] of Object.entries(profile.agents)) {
|
|
230
|
+
lines.push(` ${agent}:`);
|
|
231
|
+
const roleEntries = Object.entries(ar.roles);
|
|
232
|
+
if (roleEntries.length === 0) {
|
|
233
|
+
lines.push(" (no roles)");
|
|
234
|
+
continue;
|
|
235
|
+
}
|
|
236
|
+
for (const [role, binding] of roleEntries) {
|
|
237
|
+
lines.push(` ${role}: ${binding.model}`);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
return lines.join("\n");
|
|
241
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
// Unit tests for self-smoke classification and the final-line policy
|
|
2
|
+
// (bug: selfsmoke-failure-prints-done — a failed self-smoke must never be followed by "done.").
|
|
3
|
+
//
|
|
4
|
+
// Run: node --test src/self-smoke.test.ts
|
|
5
|
+
|
|
6
|
+
import assert from "node:assert/strict";
|
|
7
|
+
import { test } from "node:test";
|
|
8
|
+
import { classifySelfSmokeResponse, finishWireRun } from "./self-smoke.ts";
|
|
9
|
+
|
|
10
|
+
// ---- classifySelfSmokeResponse ----
|
|
11
|
+
|
|
12
|
+
test("classifySelfSmokeResponse: non-OK HTTP status is a failure", () => {
|
|
13
|
+
const r = classifySelfSmokeResponse(false, 500, "internal error");
|
|
14
|
+
assert.equal(r.ok, false);
|
|
15
|
+
assert.match(r.message, /HTTP 500/);
|
|
16
|
+
assert.match(r.message, /internal error/);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test("classifySelfSmokeResponse: 200 with a numeric version is success", () => {
|
|
20
|
+
const r = classifySelfSmokeResponse(
|
|
21
|
+
true,
|
|
22
|
+
200,
|
|
23
|
+
JSON.stringify({ sessionId: "s1", version: 3, messageCount: 1 }),
|
|
24
|
+
);
|
|
25
|
+
assert.equal(r.ok, true);
|
|
26
|
+
assert.match(r.message, /OK/);
|
|
27
|
+
assert.match(r.message, /sessionId=s1/);
|
|
28
|
+
assert.match(r.message, /version=3/);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("classifySelfSmokeResponse: 200 with non-JSON body is a failure", () => {
|
|
32
|
+
const r = classifySelfSmokeResponse(true, 200, "not json");
|
|
33
|
+
assert.equal(r.ok, false);
|
|
34
|
+
assert.match(r.message, /did not return a numeric version/);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("classifySelfSmokeResponse: 200 with JSON but no numeric version is a failure", () => {
|
|
38
|
+
const r = classifySelfSmokeResponse(true, 200, JSON.stringify({ sessionId: "s1" }));
|
|
39
|
+
assert.equal(r.ok, false);
|
|
40
|
+
assert.match(r.message, /did not return a numeric version/);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
// ---- finishWireRun ----
|
|
44
|
+
|
|
45
|
+
test("finishWireRun: failed smoke suppresses 'done.' entirely and goes to stderr", () => {
|
|
46
|
+
const f = finishWireRun({
|
|
47
|
+
smokeOk: false,
|
|
48
|
+
envVar: "PETBOX_X_API_KEY",
|
|
49
|
+
envVarPresentInProcess: true,
|
|
50
|
+
platform: "linux",
|
|
51
|
+
});
|
|
52
|
+
assert.equal(f.printDone, false);
|
|
53
|
+
assert.equal(f.toStderr, true);
|
|
54
|
+
assert.ok(f.lines.length > 0);
|
|
55
|
+
for (const line of f.lines) {
|
|
56
|
+
assert.doesNotMatch(line, /^done\.?/, "no line may read like the success banner");
|
|
57
|
+
}
|
|
58
|
+
// The literal regression this bug reported: "done." must not appear anywhere in the failure output.
|
|
59
|
+
assert.ok(!f.lines.join("\n").includes("done."));
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("finishWireRun: successful smoke + env var already present prints exactly 'done.'", () => {
|
|
63
|
+
const f = finishWireRun({
|
|
64
|
+
smokeOk: true,
|
|
65
|
+
envVar: "PETBOX_X_API_KEY",
|
|
66
|
+
envVarPresentInProcess: true,
|
|
67
|
+
platform: "linux",
|
|
68
|
+
});
|
|
69
|
+
assert.equal(f.printDone, true);
|
|
70
|
+
assert.equal(f.toStderr, false);
|
|
71
|
+
assert.deepEqual(f.lines, ["done."]);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("finishWireRun: successful smoke without the env var in-process adds the new-terminal NOTE, still to stdout", () => {
|
|
75
|
+
const f = finishWireRun({
|
|
76
|
+
smokeOk: true,
|
|
77
|
+
envVar: "PETBOX_X_API_KEY",
|
|
78
|
+
envVarPresentInProcess: false,
|
|
79
|
+
platform: "win32",
|
|
80
|
+
});
|
|
81
|
+
assert.equal(f.printDone, true);
|
|
82
|
+
assert.equal(f.toStderr, false);
|
|
83
|
+
assert.equal(f.lines.length, 1);
|
|
84
|
+
const [line] = f.lines;
|
|
85
|
+
assert.ok(line, "finishWireRun must produce exactly one line here");
|
|
86
|
+
assert.match(line, /^done\. NOTE:/);
|
|
87
|
+
assert.match(line, /PETBOX_X_API_KEY/);
|
|
88
|
+
// win32 branch omits "(login shell)"
|
|
89
|
+
assert.doesNotMatch(line, /login shell/);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("finishWireRun: POSIX platform's NOTE mentions the login shell", () => {
|
|
93
|
+
const f = finishWireRun({
|
|
94
|
+
smokeOk: true,
|
|
95
|
+
envVar: "PETBOX_X_API_KEY",
|
|
96
|
+
envVarPresentInProcess: false,
|
|
97
|
+
platform: "linux",
|
|
98
|
+
});
|
|
99
|
+
assert.equal(f.lines.length, 1);
|
|
100
|
+
const [line] = f.lines;
|
|
101
|
+
assert.ok(line, "finishWireRun must produce exactly one line here");
|
|
102
|
+
assert.match(line, /login shell/);
|
|
103
|
+
});
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
// Self-smoke response classification + final-line policy for wire's full-wiring path
|
|
2
|
+
// (wiring-one-command / selfsmoke-failure-prints-done).
|
|
3
|
+
//
|
|
4
|
+
// Kept out of wire.ts (whose main() runs at import time, so decision logic that needs to be
|
|
5
|
+
// unit-testable in isolation lives in a side module — same pattern as wire-exit.ts,
|
|
6
|
+
// wire-identity.ts, apply-write.ts).
|
|
7
|
+
//
|
|
8
|
+
// The bug: selfSmoke() set process.exitCode = 1 on failure, but main() kept going to the very
|
|
9
|
+
// end of the wiring pipeline and printed "done." regardless — a failed self-smoke was visually
|
|
10
|
+
// indistinguishable from a clean wire (the LAST line a human sees was always "done."). This
|
|
11
|
+
// module makes the terminal message set depend on the smoke outcome, so a failure IS the last
|
|
12
|
+
// line, printed to stderr (red), and "done." never follows it.
|
|
13
|
+
|
|
14
|
+
/** Pure classification of the self-smoke HTTP round trip — no network, no process state. */
|
|
15
|
+
export type SelfSmokeResult = {
|
|
16
|
+
readonly ok: boolean;
|
|
17
|
+
/** Human-facing [10/10] line. Goes to stdout when ok, stderr when not. */
|
|
18
|
+
readonly message: string;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Classify a self-smoke response. `ok`/`status` mirror fetch's Response; `text` is the already
|
|
23
|
+
* -read body (caller owns the fetch/timeout/network-error handling — those are fetch failures,
|
|
24
|
+
* not response classification, and are handled by the caller before this is ever invoked).
|
|
25
|
+
*/
|
|
26
|
+
export function classifySelfSmokeResponse(
|
|
27
|
+
respOk: boolean,
|
|
28
|
+
status: number,
|
|
29
|
+
text: string,
|
|
30
|
+
): SelfSmokeResult {
|
|
31
|
+
if (!respOk) {
|
|
32
|
+
return { ok: false, message: `[10/10] self-smoke: HTTP ${status} — ${text}` };
|
|
33
|
+
}
|
|
34
|
+
let parsed: any = null;
|
|
35
|
+
try {
|
|
36
|
+
parsed = JSON.parse(text);
|
|
37
|
+
} catch {
|
|
38
|
+
/* keep raw */
|
|
39
|
+
}
|
|
40
|
+
if (typeof parsed?.version === "number") {
|
|
41
|
+
return {
|
|
42
|
+
ok: true,
|
|
43
|
+
message:
|
|
44
|
+
`[10/10] self-smoke: OK — sessionId=${parsed.sessionId}, version=${parsed.version}, ` +
|
|
45
|
+
`messages=${parsed.messageCount}`,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
return {
|
|
49
|
+
ok: false,
|
|
50
|
+
message: `[10/10] self-smoke: server did not return a numeric version — ${text}`,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** What main() prints as its LAST lines, and where (stdout vs stderr). */
|
|
55
|
+
export type FinishOutcome = {
|
|
56
|
+
readonly lines: readonly string[];
|
|
57
|
+
/** True → every line goes to console.error (red); false → console.log. */
|
|
58
|
+
readonly toStderr: boolean;
|
|
59
|
+
/** False when self-smoke failed — "done." must never be the trailing line of a failed run. */
|
|
60
|
+
readonly printDone: boolean;
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Decide wire's terminal message set. `smokeOk` false is the ONLY branch that suppresses
|
|
65
|
+
* "done." — steps 1-9 having completed does not make the run "done" when the last barrier
|
|
66
|
+
* (self-smoke) failed.
|
|
67
|
+
*/
|
|
68
|
+
export function finishWireRun(opts: {
|
|
69
|
+
readonly smokeOk: boolean;
|
|
70
|
+
readonly envVar: string;
|
|
71
|
+
readonly envVarPresentInProcess: boolean;
|
|
72
|
+
readonly platform: NodeJS.Platform;
|
|
73
|
+
}): FinishOutcome {
|
|
74
|
+
if (!opts.smokeOk) {
|
|
75
|
+
return {
|
|
76
|
+
printDone: false,
|
|
77
|
+
toStderr: true,
|
|
78
|
+
lines: [
|
|
79
|
+
`wire: self-smoke FAILED (see [10/10] above) — steps 1-9 completed but the wiring is ` +
|
|
80
|
+
`UNVERIFIED. Treat this run as failed, not finished; exit code is non-zero.`,
|
|
81
|
+
],
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
if (opts.envVarPresentInProcess) {
|
|
85
|
+
return { printDone: true, toStderr: false, lines: ["done."] };
|
|
86
|
+
}
|
|
87
|
+
return {
|
|
88
|
+
printDone: true,
|
|
89
|
+
toStderr: false,
|
|
90
|
+
lines: [
|
|
91
|
+
`done. NOTE: start a NEW terminal${opts.platform === "win32" ? "" : " (login shell)"} before ` +
|
|
92
|
+
`launching agents — their MCP configs read ${opts.envVar} from the environment. The kit ` +
|
|
93
|
+
`hooks work immediately (keys.json).`,
|
|
94
|
+
],
|
|
95
|
+
};
|
|
96
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// Regression tests for the --telemetry OTLP export env (work telemetry-otlp-auth-401): the auth
|
|
2
|
+
// header MUST carry the RESOLVED api key and the exact header NAME the server validates, never a
|
|
3
|
+
// literal `${VAR}` placeholder (Claude Code does not expand `${VAR}` in settings.json `env`, so a
|
|
4
|
+
// placeholder is sent verbatim and the OTLP ingest returns 401).
|
|
5
|
+
//
|
|
6
|
+
// Run: node --test src/telemetry-settings.test.ts
|
|
7
|
+
|
|
8
|
+
import assert from "node:assert/strict";
|
|
9
|
+
import { test } from "node:test";
|
|
10
|
+
import {
|
|
11
|
+
buildTelemetryOtlpEnv,
|
|
12
|
+
OTLP_API_KEY_HEADER,
|
|
13
|
+
OTLP_SERVICE_KEY_HEADER,
|
|
14
|
+
OTLP_SERVICE_KEY_VALUE,
|
|
15
|
+
} from "./telemetry-settings.ts";
|
|
16
|
+
|
|
17
|
+
const BASE = "https://petbox.example";
|
|
18
|
+
const PROJECT = "acme";
|
|
19
|
+
const LOG = "cc-telemetry";
|
|
20
|
+
const KEY = "pk_live_RESOLVED_SECRET_123";
|
|
21
|
+
|
|
22
|
+
test("OTLP headers carry the RESOLVED key value, never a ${...} placeholder", () => {
|
|
23
|
+
const { secretEnv } = buildTelemetryOtlpEnv(BASE, PROJECT, KEY, LOG);
|
|
24
|
+
const headers = secretEnv["OTEL_EXPORTER_OTLP_HEADERS"];
|
|
25
|
+
assert.ok(headers, "OTEL_EXPORTER_OTLP_HEADERS must be present");
|
|
26
|
+
assert.ok(headers.includes(KEY), "header must contain the resolved key value");
|
|
27
|
+
assert.ok(!headers.includes("${"), `header must not contain a \${...} placeholder — got: ${headers}`);
|
|
28
|
+
assert.ok(!/\{env:/.test(headers), "header must not contain an {env:...} reference");
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("auth header uses the exact name the server's ApiKey scheme validates (X-Api-Key)", () => {
|
|
32
|
+
const { secretEnv } = buildTelemetryOtlpEnv(BASE, PROJECT, KEY, LOG);
|
|
33
|
+
const headers = secretEnv["OTEL_EXPORTER_OTLP_HEADERS"];
|
|
34
|
+
assert.ok(headers, "OTEL_EXPORTER_OTLP_HEADERS must be present");
|
|
35
|
+
assert.equal(OTLP_API_KEY_HEADER, "X-Api-Key");
|
|
36
|
+
assert.ok(
|
|
37
|
+
headers.includes(`${OTLP_API_KEY_HEADER}=${KEY}`),
|
|
38
|
+
`expected "X-Api-Key=<key>" pair — got: ${headers}`,
|
|
39
|
+
);
|
|
40
|
+
// Must NOT use the bare self-export routes' shared-secret header (those routes aren't targeted).
|
|
41
|
+
assert.ok(!/X-Seq-ApiKey/i.test(headers), "must not use X-Seq-ApiKey (wrong route's auth header)");
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("service-key header pair is present (IngestLogs 400s without X-Service-Key)", () => {
|
|
45
|
+
const { secretEnv } = buildTelemetryOtlpEnv(BASE, PROJECT, KEY, LOG);
|
|
46
|
+
const headers = secretEnv["OTEL_EXPORTER_OTLP_HEADERS"];
|
|
47
|
+
assert.ok(headers, "OTEL_EXPORTER_OTLP_HEADERS must be present");
|
|
48
|
+
assert.ok(
|
|
49
|
+
headers.includes(`${OTLP_SERVICE_KEY_HEADER}=${OTLP_SERVICE_KEY_VALUE}`),
|
|
50
|
+
"X-Service-Key=claude-code pair must be present",
|
|
51
|
+
);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("the secret (key-bearing) env is split out from the committable public env", () => {
|
|
55
|
+
const { publicEnv, secretEnv } = buildTelemetryOtlpEnv(BASE, PROJECT, KEY, LOG);
|
|
56
|
+
// The key must live only in the secret env (destined for gitignored settings.local.json).
|
|
57
|
+
assert.ok(!JSON.stringify(publicEnv).includes(KEY), "public env must never contain the api key");
|
|
58
|
+
assert.ok(Object.keys(secretEnv).length === 1, "secret env carries only the auth header");
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("endpoints are the path-based routes (project + log in the URL path)", () => {
|
|
62
|
+
const { publicEnv } = buildTelemetryOtlpEnv(BASE, PROJECT, KEY, LOG);
|
|
63
|
+
assert.equal(publicEnv["OTEL_EXPORTER_OTLP_METRICS_ENDPOINT"], `${BASE}/v1/metrics/${PROJECT}/${LOG}`);
|
|
64
|
+
assert.equal(publicEnv["OTEL_EXPORTER_OTLP_LOGS_ENDPOINT"], `${BASE}/v1/logs/${PROJECT}/${LOG}`);
|
|
65
|
+
});
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// The OTLP export env that `--telemetry` writes into Claude Code's settings, extracted into its
|
|
2
|
+
// own importable module (wire.ts runs main() at module top level and must never be imported by a
|
|
3
|
+
// test — same reason posix-env.ts exists). The env-string building here is the load-bearing,
|
|
4
|
+
// regression-prone part: it must emit a RESOLVED api key and the exact auth header the server
|
|
5
|
+
// validates. wire.ts owns the file-merge glue (mergeEnvIntoSettings) and calls this.
|
|
6
|
+
|
|
7
|
+
// Auth header the PetBox path-based OTLP routes (/v1/{signal}/{project}/{log}) validate. Those
|
|
8
|
+
// routes use RequireAuthorization("ApiKey"), and the ApiKey scheme reads X-Api-Key
|
|
9
|
+
// (PetBox.Core/Auth/ApiKeyAuthenticationHandler.cs: ApiKeyHeader = "X-Api-Key"). This is NOT the
|
|
10
|
+
// bare self-export routes' X-Seq-ApiKey (those are AllowAnonymous + a shared self-log secret and
|
|
11
|
+
// are not what telemetry targets), so X-Api-Key is the correct name here.
|
|
12
|
+
export const OTLP_API_KEY_HEADER = "X-Api-Key";
|
|
13
|
+
// IngestLogs additionally requires a non-empty X-Service-Key header (400 otherwise); it only tags
|
|
14
|
+
// the emitter (free string, no Service entity).
|
|
15
|
+
export const OTLP_SERVICE_KEY_HEADER = "X-Service-Key";
|
|
16
|
+
export const OTLP_SERVICE_KEY_VALUE = "claude-code";
|
|
17
|
+
|
|
18
|
+
export interface TelemetryOtlpEnv {
|
|
19
|
+
// Non-secret export config → committable .claude/settings.json.
|
|
20
|
+
publicEnv: Record<string, string>;
|
|
21
|
+
// API-key-bearing header → gitignored .claude/settings.local.json.
|
|
22
|
+
secretEnv: Record<string, string>;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Build the OTLP export env split by secrecy.
|
|
26
|
+
//
|
|
27
|
+
// The api key is written RESOLVED (the literal key value), NOT as a `${VAR}` reference: Claude Code
|
|
28
|
+
// does NOT expand `${VAR}` inside settings.json `env` values (unlike .mcp.json) — empirically
|
|
29
|
+
// verified 2026-07-06 — so a reference form would send the literal string `${VAR}` and the OTLP
|
|
30
|
+
// ingest returns 401. Tradeoff: a literal key PINS the value, so if the project api key rotates the
|
|
31
|
+
// header goes stale — re-run wire (--telemetry) to re-provision. The key already lives plaintext in
|
|
32
|
+
// ~/.petbox/keys.json; settings.local.json (gitignored) is the same trust boundary, per-project.
|
|
33
|
+
export function buildTelemetryOtlpEnv(
|
|
34
|
+
baseUrl: string,
|
|
35
|
+
project: string,
|
|
36
|
+
key: string,
|
|
37
|
+
logName: string,
|
|
38
|
+
): TelemetryOtlpEnv {
|
|
39
|
+
const metricsEndpoint = `${baseUrl}/v1/metrics/${project}/${logName}`;
|
|
40
|
+
const logsEndpoint = `${baseUrl}/v1/logs/${project}/${logName}`;
|
|
41
|
+
const publicEnv: Record<string, string> = {
|
|
42
|
+
CLAUDE_CODE_ENABLE_TELEMETRY: "1",
|
|
43
|
+
OTEL_METRICS_EXPORTER: "otlp",
|
|
44
|
+
OTEL_LOGS_EXPORTER: "otlp",
|
|
45
|
+
OTEL_EXPORTER_OTLP_PROTOCOL: "http/protobuf",
|
|
46
|
+
OTEL_EXPORTER_OTLP_METRICS_ENDPOINT: metricsEndpoint,
|
|
47
|
+
OTEL_EXPORTER_OTLP_LOGS_ENDPOINT: logsEndpoint,
|
|
48
|
+
OTEL_METRIC_EXPORT_INTERVAL: "5000",
|
|
49
|
+
};
|
|
50
|
+
const secretEnv: Record<string, string> = {
|
|
51
|
+
// OTLP header list format: comma-separated key=value pairs. Resolved key, correct header name.
|
|
52
|
+
OTEL_EXPORTER_OTLP_HEADERS: `${OTLP_API_KEY_HEADER}=${key},${OTLP_SERVICE_KEY_HEADER}=${OTLP_SERVICE_KEY_VALUE}`,
|
|
53
|
+
};
|
|
54
|
+
return { publicEnv, secretEnv };
|
|
55
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: petbox
|
|
3
|
+
description: Shared task boards, memory and session plans for this project via the PetBox MCP server (server name `petbox`). Use to record/read plans, durable notes and working-session state for {{PROJECT}} development.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
This project is connected to a PetBox instance over MCP (server `petbox`, https://petbox.3po.su).
|
|
7
|
+
Pass projectKey "{{PROJECT}}" in every call (the key is scoped to the {{PROJECT}} project;
|
|
8
|
+
boards/memory/sessions live at https://petbox.3po.su/ui/{{WORKSPACE}}/{{PROJECT}}).
|
|
9
|
+
|
|
10
|
+
**Tool naming:** the base verbs are underscore-delimited (`tasks_upsert`, `memory_search`, …).
|
|
11
|
+
In opencode the MCP tools are `petbox_<verb>` (e.g. `petbox_tasks_upsert`, `petbox_memory_search`);
|
|
12
|
+
in Claude Code they are `mcp__petbox__<verb>`. Just prefix the base verb per runtime.
|
|
13
|
+
|
|
14
|
+
**Plan nodes are FLAT slugs** (`key` = [a-z][a-z0-9_-]*); hierarchy is the `partOf` edge,
|
|
15
|
+
grouping is `tags` (`area:*` / `concern:*`). Give each node a short `title` and a markdown
|
|
16
|
+
`body`. A cold `tasks_upsert` auto-creates the board. The upsert response is a pure ack for
|
|
17
|
+
YOUR call (added/updated/removed cover only your nodes); to catch up on everyone's changes
|
|
18
|
+
call `tasks_delta` with `sinceVersion` = a previous `currentVersion`. `nodes`/`entries` are
|
|
19
|
+
TYPED arrays — pass real JSON arrays, not stringified JSON.
|
|
20
|
+
|
|
21
|
+
**`tasks_search` is THE read verb** — two modes: without `q` it's a deterministic LISTING
|
|
22
|
+
(pass `board` for one board, omit for the whole project; default order priority-then-key),
|
|
23
|
+
with `q` it's hybrid relevance search (FTS ⊕ vectors). Filters work in both modes:
|
|
24
|
+
`status[]`, `keys[]` (slug|NodeId), `under` (subtree), `includeClosed`; `sort{by,desc}`
|
|
25
|
+
reorders; `bodyLen` snippets bodies. One node in full: `tasks_node_get`.
|
|
26
|
+
|
|
27
|
+
**Memory entries are typed** (`user` | `feedback` | `project` | `reference`) — `type` is
|
|
28
|
+
required on `memory_upsert`; `tags` is an ARRAY of strings ([] clears, omit keeps).
|
|
29
|
+
`memory_search` is THE read verb: with `q`
|
|
30
|
+
a hybrid relevance search (FTS ⊕ vectors), without `q` a deterministic listing (updated
|
|
31
|
+
desc); no `scope` cascades project ⊕ workspace over every store (use `bodyLen` for snippets).
|
|
32
|
+
|
|
33
|
+
**What goes where:**
|
|
34
|
+
- Session (`session_*`) — the current working plan/thinking. "Stale next week?" → session.
|
|
35
|
+
- Tasks (`tasks_*`) — a unit of work with a status tracked to Done.
|
|
36
|
+
- Memory (`memory_*`) — a durable fact that should outlive the work. Don't store what
|
|
37
|
+
code/git already records, transient state, secrets, or actionable work (that's a task).
|
|
38
|
+
|
|
39
|
+
**Tools:**
|
|
40
|
+
- `tasks_board_list / board_create / board_delete / search / node_get / upsert / delta / workflow`
|
|
41
|
+
- `memory_store_list / store_create / store_delete / search / remember / get / upsert / delta`
|
|
42
|
+
- `session_search / get / upsert / append / delete` (`search` without `q` = the session listing; with `q` = two-stage archive search whose hits carry message ordinals for `session_get`)
|
|
43
|
+
- Logs: `log_query` (KQL), `log_create / list / delete`
|
|
44
|
+
- Admin (per-type, flat params): `project_create / list`, `apikey_create / list / delete`,
|
|
45
|
+
`db_create / list / delete / describe`
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: petbox-agent-factory
|
|
3
|
+
description: On-demand compile of per-harness agent artifacts from portable PetBox definitions + local role→model bindings. Use after role/profile changes — not every session; never invent models; never put this procedure into canon.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Agent factory (on-demand skill)
|
|
7
|
+
|
|
8
|
+
Factory is an **on-demand skill**, not session canon. Run it when definitions or local
|
|
9
|
+
bindings change; do **not** re-run every session and do **not** paste this procedure into
|
|
10
|
+
hooks, protocol, or memory canon.
|
|
11
|
+
|
|
12
|
+
## Axis
|
|
13
|
+
|
|
14
|
+
| What | Where |
|
|
15
|
+
| --- | --- |
|
|
16
|
+
| Portable definition | PetBox (`agent_def_*` / REST agent-defs) — roles/capabilities, **no models** |
|
|
17
|
+
| Local binding | `~/.petbox/roles.json` (owner = `$HOME`) — profile → role → **model** only |
|
|
18
|
+
| Compiled artifacts | Per-harness agent files written by `apply` |
|
|
19
|
+
|
|
20
|
+
Never invent a model id. If a role has no binding, leave model unset and report it.
|
|
21
|
+
Owner axis is `$HOME`; definitions are portable across machines, models are not.
|
|
22
|
+
|
|
23
|
+
## Procedure
|
|
24
|
+
|
|
25
|
+
1. Inspect / switch local binding as needed:
|
|
26
|
+
```bash
|
|
27
|
+
npx petbox-wire roles
|
|
28
|
+
npx petbox-wire profile use <name>
|
|
29
|
+
```
|
|
30
|
+
2. Gate:
|
|
31
|
+
```bash
|
|
32
|
+
npx petbox-wire doctor
|
|
33
|
+
```
|
|
34
|
+
Doctor reports truthfulness violations (role + capability + harness). Exit 1 on failure — fix before apply.
|
|
35
|
+
3. Materialize:
|
|
36
|
+
```bash
|
|
37
|
+
npx petbox-wire apply
|
|
38
|
+
```
|
|
39
|
+
Apply writes per-harness agent files from the definition + local binding; `model:` only when bound. Any harness violation blocks all writes.
|
|
40
|
+
|
|
41
|
+
## Do not confuse with `update`
|
|
42
|
+
|
|
43
|
+
| Command | Effect |
|
|
44
|
+
| --- | --- |
|
|
45
|
+
| `npx petbox-wire apply` | Compile per-harness agent artifacts from definition + binding |
|
|
46
|
+
| `npx petbox-wire update` | Refresh only the stable kit under `~/.petbox/wire/` (hooks/scripts/templates) |
|
|
47
|
+
|
|
48
|
+
`update` does **not** rebuild agent artifacts. After a kit-text change that includes this
|
|
49
|
+
skill template, re-run a **full wire** to reinstall skill files into the project.
|
|
50
|
+
|
|
51
|
+
## Boundaries
|
|
52
|
+
|
|
53
|
+
- Factory procedure stays in this skill — not in `protocol.ts`, SessionStart canon, or AGENTS.md.
|
|
54
|
+
- Portable defs ship without models; local `roles.json` is the only model source.
|
|
55
|
+
- Prefer reporting missing bindings over inventing them.
|
|
56
|
+
- Not canon; on-demand only.
|