@joenandez/academy 0.4.0-rc.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +14 -0
- package/.claude-plugin/plugin.json +6 -0
- package/CHANGELOG.md +46 -0
- package/LICENSE +21 -0
- package/README.md +209 -0
- package/bin/academy +2 -0
- package/conformance/README.md +60 -0
- package/conformance/discovery.test.mjs +140 -0
- package/conformance/envelope.test.mjs +185 -0
- package/conformance/error-codes.test.mjs +125 -0
- package/conformance/harness.mjs +180 -0
- package/conformance/identity.test.mjs +125 -0
- package/docs/integration-guide.md +1026 -0
- package/hooks/hook_runtime.mjs +100 -0
- package/hooks/hooks.json +26 -0
- package/hooks/inject_surface.py +122 -0
- package/hooks/memory_bridge.mjs +120 -0
- package/hooks/memory_store.mjs +66 -0
- package/hooks/register_session.mjs +51 -0
- package/hooks/sync_memory.mjs +27 -0
- package/package.json +41 -0
- package/scripts/agent.mjs +3 -0
- package/scripts/cli/archive.mjs +161 -0
- package/scripts/cli/archived.mjs +82 -0
- package/scripts/cli/args.mjs +282 -0
- package/scripts/cli/codex.mjs +216 -0
- package/scripts/cli/core.mjs +389 -0
- package/scripts/cli/create.mjs +242 -0
- package/scripts/cli/doctor.mjs +203 -0
- package/scripts/cli/eventlog.mjs +129 -0
- package/scripts/cli/events.mjs +80 -0
- package/scripts/cli/hire-headless.mjs +229 -0
- package/scripts/cli/hire-spec.mjs +164 -0
- package/scripts/cli/hire.mjs +92 -0
- package/scripts/cli/inspect.mjs +286 -0
- package/scripts/cli/lifecycle.mjs +296 -0
- package/scripts/cli/main.mjs +102 -0
- package/scripts/cli/migrate.mjs +183 -0
- package/scripts/cli/notes.mjs +104 -0
- package/scripts/cli/rename.mjs +172 -0
- package/scripts/cli/run.mjs +227 -0
- package/scripts/cli/runtime.mjs +47 -0
- package/scripts/cli/scaffold.mjs +332 -0
- package/scripts/cli/sessions.mjs +98 -0
- package/scripts/cli/templates.mjs +104 -0
- package/scripts/cli/yaml.mjs +124 -0
- package/skills/hire/SKILL.md +669 -0
- package/templates/agents/claude-code/knowledge-curator.md +14 -0
- package/templates/agents/codex/knowledge-curator.toml +9 -0
- package/templates/skills/check-in/SKILL.md +122 -0
- package/templates/skills/knowledge-curation/SKILL.md +132 -0
- package/templates/skills/nightly-consolidation/SKILL.md +240 -0
- package/templates/skills/self-update/SKILL.md +121 -0
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
// The envelope, swept across every published command.
|
|
2
|
+
//
|
|
3
|
+
// success stdout, exit 0 { contract_version, ok: true, command, ...payload }
|
|
4
|
+
// failure stderr, exit 1 { contract_version, ok: false, command, error }
|
|
5
|
+
//
|
|
6
|
+
// Exit status is 0 if and only if `ok` is true. The table is checked against
|
|
7
|
+
// the command list `doctor` publishes, so a build that publishes a command this
|
|
8
|
+
// suite has no case for fails here rather than going unchecked.
|
|
9
|
+
|
|
10
|
+
import assert from 'node:assert/strict';
|
|
11
|
+
import { symlinkSync, writeFileSync } from 'node:fs';
|
|
12
|
+
import { join } from 'node:path';
|
|
13
|
+
import test from 'node:test';
|
|
14
|
+
import {
|
|
15
|
+
academy,
|
|
16
|
+
assertFailure,
|
|
17
|
+
assertSuccess,
|
|
18
|
+
createHost,
|
|
19
|
+
hireAgent,
|
|
20
|
+
publishedCommands,
|
|
21
|
+
} from './harness.mjs';
|
|
22
|
+
|
|
23
|
+
function agentHost() {
|
|
24
|
+
const host = createHost();
|
|
25
|
+
hireAgent(host, 'kai');
|
|
26
|
+
return host;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function archivedHost() {
|
|
30
|
+
const host = agentHost();
|
|
31
|
+
assertSuccess(academy(host, ['archive', 'kai', '--json']), 'archive');
|
|
32
|
+
return host;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// An agents root that resolves outside itself — the one state that fails every
|
|
36
|
+
// command reading the root, and so the failure case for the two that address no
|
|
37
|
+
// agent and reject no option.
|
|
38
|
+
function escapedHost() {
|
|
39
|
+
const host = createHost();
|
|
40
|
+
symlinkSync(host.outside, host.agentsRoot);
|
|
41
|
+
return host;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function specHost() {
|
|
45
|
+
const host = createHost();
|
|
46
|
+
const body = { name: 'kai', role: 'conformance subject', objective: 'exist' };
|
|
47
|
+
writeFileSync(join(host.root, 'spec.json'), JSON.stringify(body));
|
|
48
|
+
return host;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// `hire` without --spec execs into an interactive session with inherited stdio
|
|
52
|
+
// and can never print an envelope, so the headless form is the one under test.
|
|
53
|
+
const SPEC = (host) => join(host.root, 'spec.json');
|
|
54
|
+
|
|
55
|
+
const CASES = {
|
|
56
|
+
doctor: [createHost, () => ['doctor'], escapedHost, () => ['doctor'], 'unsafe_agent_path'],
|
|
57
|
+
list: [createHost, () => ['list'], escapedHost, () => ['list'], 'unsafe_agent_path'],
|
|
58
|
+
inspect: [
|
|
59
|
+
agentHost,
|
|
60
|
+
() => ['inspect', 'kai'],
|
|
61
|
+
createHost,
|
|
62
|
+
() => ['inspect', 'ghost'],
|
|
63
|
+
'agent_not_found',
|
|
64
|
+
],
|
|
65
|
+
tokens: [
|
|
66
|
+
agentHost,
|
|
67
|
+
() => ['tokens', 'kai'],
|
|
68
|
+
createHost,
|
|
69
|
+
() => ['tokens', 'ghost'],
|
|
70
|
+
'agent_not_found',
|
|
71
|
+
],
|
|
72
|
+
budget: [
|
|
73
|
+
agentHost,
|
|
74
|
+
() => ['budget', 'kai'],
|
|
75
|
+
createHost,
|
|
76
|
+
() => ['budget', 'ghost'],
|
|
77
|
+
'agent_not_found',
|
|
78
|
+
],
|
|
79
|
+
sessions: [
|
|
80
|
+
createHost,
|
|
81
|
+
() => ['sessions'],
|
|
82
|
+
createHost,
|
|
83
|
+
() => ['sessions', '--nope'],
|
|
84
|
+
'invalid_spec',
|
|
85
|
+
],
|
|
86
|
+
events: [
|
|
87
|
+
createHost,
|
|
88
|
+
() => ['events', '--since', '0'],
|
|
89
|
+
createHost,
|
|
90
|
+
() => ['events', '--since', '99999'],
|
|
91
|
+
'replay_unavailable',
|
|
92
|
+
],
|
|
93
|
+
create: [createHost, () => ['create', 'kai'], agentHost, () => ['create', 'kai'], 'agent_exists'],
|
|
94
|
+
hire: [
|
|
95
|
+
specHost,
|
|
96
|
+
(h) => ['hire', '--spec', SPEC(h)],
|
|
97
|
+
createHost,
|
|
98
|
+
(h) => ['hire', '--spec', SPEC(h)],
|
|
99
|
+
'invalid_spec',
|
|
100
|
+
],
|
|
101
|
+
rename: [
|
|
102
|
+
agentHost,
|
|
103
|
+
() => ['rename', 'kai', 'nova'],
|
|
104
|
+
createHost,
|
|
105
|
+
() => ['rename', 'ghost', 'nova'],
|
|
106
|
+
'agent_not_found',
|
|
107
|
+
],
|
|
108
|
+
archive: [
|
|
109
|
+
agentHost,
|
|
110
|
+
() => ['archive', 'kai'],
|
|
111
|
+
createHost,
|
|
112
|
+
() => ['archive', 'ghost'],
|
|
113
|
+
'agent_not_found',
|
|
114
|
+
],
|
|
115
|
+
unarchive: [
|
|
116
|
+
archivedHost,
|
|
117
|
+
() => ['unarchive', 'kai'],
|
|
118
|
+
createHost,
|
|
119
|
+
() => ['unarchive', 'ghost'],
|
|
120
|
+
'agent_not_found',
|
|
121
|
+
],
|
|
122
|
+
delete: [
|
|
123
|
+
agentHost,
|
|
124
|
+
() => ['delete', 'kai'],
|
|
125
|
+
createHost,
|
|
126
|
+
() => ['delete', 'ghost'],
|
|
127
|
+
'agent_not_found',
|
|
128
|
+
],
|
|
129
|
+
migrate: [createHost, () => ['migrate'], createHost, () => ['migrate', '--nope'], 'invalid_spec'],
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
test('every published command has a success case and a failure case', () => {
|
|
133
|
+
const published = publishedCommands(createHost());
|
|
134
|
+
|
|
135
|
+
assert.deepEqual([...published].sort(), Object.keys(CASES).sort());
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
for (const [command, [okHost, okArgs, failHost, failArgs, code]] of Object.entries(CASES)) {
|
|
139
|
+
test(`${command} answers success in the envelope with exit 0`, () => {
|
|
140
|
+
const host = okHost();
|
|
141
|
+
|
|
142
|
+
const result = academy(host, [...okArgs(host), '--json']);
|
|
143
|
+
|
|
144
|
+
assertSuccess(result, command);
|
|
145
|
+
assert.equal(result.status, 0);
|
|
146
|
+
assert.equal(result.stderr, '');
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
test(`${command} answers failure in the envelope with a non-zero exit`, () => {
|
|
150
|
+
const host = failHost();
|
|
151
|
+
|
|
152
|
+
const result = academy(host, [...failArgs(host), '--json']);
|
|
153
|
+
|
|
154
|
+
assertFailure(result, command, code);
|
|
155
|
+
assert.notEqual(result.status, 0);
|
|
156
|
+
assert.equal(result.stdout, '');
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// The one success that carries a false flag in its payload. The exit rule
|
|
161
|
+
// follows `ok`, never the payload, so a client must not read a budget breach as
|
|
162
|
+
// a failed command.
|
|
163
|
+
test('a payload reporting a breach is still ok:true and exit 0', () => {
|
|
164
|
+
const host = agentHost();
|
|
165
|
+
writeFileSync(join(host.agentsRoot, 'kai', 'knowledge.md'), 'over the cap. '.repeat(4000));
|
|
166
|
+
|
|
167
|
+
const result = academy(host, ['budget', 'kai', '--json']);
|
|
168
|
+
|
|
169
|
+
const payload = assertSuccess(result, 'budget');
|
|
170
|
+
assert.equal(result.status, 0);
|
|
171
|
+
assert.equal(payload.withinBudget, false);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
// The health channel is not the error channel: a degraded component is reported
|
|
175
|
+
// beside an ok:true answer, and the exit rule still follows `ok`.
|
|
176
|
+
test('doctor reporting health errors is still ok:true and exit 0', () => {
|
|
177
|
+
const host = agentHost();
|
|
178
|
+
writeFileSync(join(host.agentsRoot, 'kai', 'agent.yaml'), 'name: kai\nruntime: mainframe\n');
|
|
179
|
+
|
|
180
|
+
const result = academy(host, ['doctor', '--json']);
|
|
181
|
+
|
|
182
|
+
const payload = assertSuccess(result, 'doctor');
|
|
183
|
+
assert.equal(result.status, 0);
|
|
184
|
+
assert.deepEqual(payload.errors, [{ code: 'invalid_runtime_agents', count: 1 }]);
|
|
185
|
+
});
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
// The published error codes, closed at contract_version 1.
|
|
2
|
+
//
|
|
3
|
+
// Fifteen codes, one invocation each. A client may switch on `error.code`, so
|
|
4
|
+
// each row proves the code string a build actually emits and that the exit
|
|
5
|
+
// status is non-zero. A build that answers a different code, or answers the
|
|
6
|
+
// right code with exit 0, is not conforming.
|
|
7
|
+
|
|
8
|
+
import assert from 'node:assert/strict';
|
|
9
|
+
import { mkdirSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
|
|
10
|
+
import { join } from 'node:path';
|
|
11
|
+
import test from 'node:test';
|
|
12
|
+
import { academy, assertFailure, createHost, hireAgent } from './harness.mjs';
|
|
13
|
+
|
|
14
|
+
const PUBLISHED_ERROR_CODES = [
|
|
15
|
+
'agent_not_found',
|
|
16
|
+
'unsafe_agent_path',
|
|
17
|
+
'not_academy_owned',
|
|
18
|
+
'invalid_name',
|
|
19
|
+
'agent_exists',
|
|
20
|
+
'agent_archived',
|
|
21
|
+
'replay_unavailable',
|
|
22
|
+
'log_corrupt',
|
|
23
|
+
'invalid_runtime',
|
|
24
|
+
'invalid_spec',
|
|
25
|
+
'runtime_unavailable',
|
|
26
|
+
'lock_timeout',
|
|
27
|
+
'internal_error',
|
|
28
|
+
'unschedule_failed',
|
|
29
|
+
'unschedule_failed_restore_blocked',
|
|
30
|
+
];
|
|
31
|
+
|
|
32
|
+
function marker(host, name) {
|
|
33
|
+
return join(host.agentsRoot, name, '.academy-agent.json');
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function spec(host, body) {
|
|
37
|
+
const path = join(host.root, 'spec.json');
|
|
38
|
+
writeFileSync(path, typeof body === 'string' ? body : JSON.stringify(body));
|
|
39
|
+
return path;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// One row per code: a host, an invocation, and the command that answers it.
|
|
43
|
+
// Each returns { command, args } and may prepare the host first.
|
|
44
|
+
const INVOCATIONS = {
|
|
45
|
+
agent_not_found: () => ({ command: 'inspect', args: ['inspect', 'ghost'] }),
|
|
46
|
+
invalid_name: () => ({ command: 'inspect', args: ['inspect', 'Not A Name'] }),
|
|
47
|
+
agent_exists: (host) => {
|
|
48
|
+
hireAgent(host, 'kai');
|
|
49
|
+
return { command: 'create', args: ['create', 'kai'] };
|
|
50
|
+
},
|
|
51
|
+
agent_archived: (host) => {
|
|
52
|
+
hireAgent(host, 'kai');
|
|
53
|
+
academy(host, ['archive', 'kai', '--json']);
|
|
54
|
+
return { command: 'inspect', args: ['inspect', 'kai'] };
|
|
55
|
+
},
|
|
56
|
+
unsafe_agent_path: (host) => {
|
|
57
|
+
symlinkSync(host.outside, host.agentsRoot);
|
|
58
|
+
return { command: 'list', args: ['list'] };
|
|
59
|
+
},
|
|
60
|
+
not_academy_owned: (host) => {
|
|
61
|
+
hireAgent(host, 'kai');
|
|
62
|
+
rmSync(marker(host, 'kai'));
|
|
63
|
+
return { command: 'rename', args: ['rename', 'kai', 'nova'] };
|
|
64
|
+
},
|
|
65
|
+
invalid_runtime: (host) => {
|
|
66
|
+
hireAgent(host, 'kai');
|
|
67
|
+
writeFileSync(join(host.agentsRoot, 'kai', 'agent.yaml'), 'name: kai\nruntime: mainframe\n');
|
|
68
|
+
return { command: 'inspect', args: ['inspect', 'kai'] };
|
|
69
|
+
},
|
|
70
|
+
invalid_spec: (host) => ({ command: 'hire', args: ['hire', '--spec', spec(host, '{ not json')] }),
|
|
71
|
+
replay_unavailable: () => ({ command: 'events', args: ['events', '--since', '999999'] }),
|
|
72
|
+
log_corrupt: (host) => {
|
|
73
|
+
writeFileSync(host.eventLog, 'this line is not a record\n');
|
|
74
|
+
return { command: 'create', args: ['create', 'kai'] };
|
|
75
|
+
},
|
|
76
|
+
runtime_unavailable: () => ({ command: 'create', args: ['create', 'kai'] }),
|
|
77
|
+
lock_timeout: (host) => {
|
|
78
|
+
hireAgent(host, 'kai');
|
|
79
|
+
mkdirSync(join(host.agentsRoot, '.kai.lifecycle.lock'));
|
|
80
|
+
return { command: 'delete', args: ['delete', 'kai'] };
|
|
81
|
+
},
|
|
82
|
+
internal_error: (host) => {
|
|
83
|
+
hireAgent(host, 'kai');
|
|
84
|
+
rmSync(join(host.agentsRoot, 'kai', 'notes.md'));
|
|
85
|
+
mkdirSync(join(host.agentsRoot, 'kai', 'notes.md'));
|
|
86
|
+
return { command: 'budget', args: ['budget', 'kai'] };
|
|
87
|
+
},
|
|
88
|
+
unschedule_failed: (host) => {
|
|
89
|
+
hireAgent(host, 'kai');
|
|
90
|
+
return { command: 'delete', args: ['delete', 'kai'] };
|
|
91
|
+
},
|
|
92
|
+
unschedule_failed_restore_blocked: (host) => {
|
|
93
|
+
hireAgent(host, 'kai');
|
|
94
|
+
return { command: 'delete', args: ['delete', 'kai'] };
|
|
95
|
+
},
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
// The host each code needs. `runtime_unavailable` needs a PATH with no
|
|
99
|
+
// scheduler; the two unschedule codes need a scheduler that fails to remove a
|
|
100
|
+
// job, and the second needs one that refills the slot while it fails.
|
|
101
|
+
const HOSTS = {
|
|
102
|
+
runtime_unavailable: { scheduler: 'missing' },
|
|
103
|
+
unschedule_failed: { scheduler: 'unscheduleFails' },
|
|
104
|
+
unschedule_failed_restore_blocked: { scheduler: 'unscheduleFailsAndRefillsTheSlot' },
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
test('the published error-code table is closed at fifteen codes', () => {
|
|
108
|
+
assert.deepEqual(Object.keys(INVOCATIONS).sort(), [...PUBLISHED_ERROR_CODES].sort());
|
|
109
|
+
assert.equal(PUBLISHED_ERROR_CODES.length, 15);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
for (const code of PUBLISHED_ERROR_CODES) {
|
|
113
|
+
test(`${code} is reachable and answered in the envelope`, () => {
|
|
114
|
+
const host = createHost(HOSTS[code]);
|
|
115
|
+
const { command, args } = INVOCATIONS[code](host);
|
|
116
|
+
|
|
117
|
+
const result = academy(host, [...args, '--json']);
|
|
118
|
+
|
|
119
|
+
const envelope = assertFailure(result, command, code);
|
|
120
|
+
assert.notEqual(result.status, 0);
|
|
121
|
+
assert.equal(typeof envelope.error.message, 'string');
|
|
122
|
+
assert.notEqual(envelope.error.message, '');
|
|
123
|
+
assert.equal(result.stdout, '');
|
|
124
|
+
});
|
|
125
|
+
}
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
// Academy client conformance suite — test harness.
|
|
2
|
+
//
|
|
3
|
+
// This suite is for authors of clients that drive Academy. It treats Academy as
|
|
4
|
+
// a black box: it never imports Academy source, and every assertion is made
|
|
5
|
+
// against the response envelope a client actually parses.
|
|
6
|
+
//
|
|
7
|
+
// Point it at any Academy build with ACADEMY_BIN. Unset, it drives the
|
|
8
|
+
// `bin/academy` beside this directory.
|
|
9
|
+
//
|
|
10
|
+
// ACADEMY_BIN=/usr/local/bin/academy node --test conformance/*.test.mjs
|
|
11
|
+
//
|
|
12
|
+
// SAFETY: every host below is built from an empty environment. HOME and
|
|
13
|
+
// AGENTS_ROOT are fresh temporary directories, so the suite can drive the whole
|
|
14
|
+
// lifecycle — including delete, archive, rename and migrate — with no way to
|
|
15
|
+
// reach the agents on the machine running it. Nothing is inherited: an
|
|
16
|
+
// AGENTS_ROOT or HOME already exported into your shell is not passed to the
|
|
17
|
+
// binary under test.
|
|
18
|
+
|
|
19
|
+
import { chmodSync, mkdirSync, mkdtempSync, writeFileSync } from 'node:fs';
|
|
20
|
+
import { spawnSync } from 'node:child_process';
|
|
21
|
+
import { tmpdir } from 'node:os';
|
|
22
|
+
import { dirname, join, resolve } from 'node:path';
|
|
23
|
+
import { fileURLToPath } from 'node:url';
|
|
24
|
+
|
|
25
|
+
/** The build under test. */
|
|
26
|
+
const ACADEMY_BIN = process.env.ACADEMY_BIN
|
|
27
|
+
? resolve(process.env.ACADEMY_BIN)
|
|
28
|
+
: fileURLToPath(new URL('../bin/academy', import.meta.url));
|
|
29
|
+
|
|
30
|
+
/** The contract version this suite was written against. */
|
|
31
|
+
const CONTRACT_VERSION = 1;
|
|
32
|
+
|
|
33
|
+
// Academy registers each agent's nightly consolidation job through a scheduler
|
|
34
|
+
// it resolves from PATH as `helm-tasks`, and it launches Claude Code as
|
|
35
|
+
// `claude`. Neither is part of the published response contract, and a client
|
|
36
|
+
// author must not need either installed to check conformance, so each host gets
|
|
37
|
+
// its own stubs. The scheduler stub is also how the suite reaches the two
|
|
38
|
+
// unschedule failure codes, which no other input can produce.
|
|
39
|
+
const SCHEDULER_STUBS = {
|
|
40
|
+
ok: '#!/bin/sh\nexit 0\n',
|
|
41
|
+
unscheduleFails: schedulerStub(false),
|
|
42
|
+
unscheduleFailsAndRefillsTheSlot: schedulerStub(true),
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
function schedulerStub(refillSlot) {
|
|
46
|
+
return [
|
|
47
|
+
'#!/bin/sh',
|
|
48
|
+
'action="$1"',
|
|
49
|
+
'dir=""',
|
|
50
|
+
'while [ "$#" -gt 0 ]; do',
|
|
51
|
+
' case "$1" in --cwd) dir="$2" ;; esac',
|
|
52
|
+
' shift',
|
|
53
|
+
'done',
|
|
54
|
+
'if [ "$action" = "delete" ]; then',
|
|
55
|
+
refillSlot ? ' mkdir -p "$dir"' : ' :',
|
|
56
|
+
' exit 3',
|
|
57
|
+
'fi',
|
|
58
|
+
'exit 0',
|
|
59
|
+
].join('\n');
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const RUNTIME_STUB = '#!/bin/sh\nexit 0\n';
|
|
63
|
+
|
|
64
|
+
function writeExecutable(path, body) {
|
|
65
|
+
writeFileSync(path, body);
|
|
66
|
+
chmodSync(path, 0o755);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* A throwaway Academy installation target: its own HOME, its own AGENTS_ROOT,
|
|
71
|
+
* its own PATH. `scheduler: 'missing'` installs no scheduler stub, which is how
|
|
72
|
+
* a host without the scheduler binary is reached.
|
|
73
|
+
*/
|
|
74
|
+
export function createHost({ scheduler = 'ok' } = {}) {
|
|
75
|
+
const root = mkdtempSync(join(tmpdir(), 'academy-conformance-'));
|
|
76
|
+
const host = {
|
|
77
|
+
root,
|
|
78
|
+
home: join(root, 'home'),
|
|
79
|
+
agentsRoot: join(root, 'agents'),
|
|
80
|
+
binDir: join(root, 'bin'),
|
|
81
|
+
outside: join(root, 'outside'),
|
|
82
|
+
eventLog: join(root, 'events.jsonl'),
|
|
83
|
+
};
|
|
84
|
+
for (const dir of [host.home, host.binDir, host.outside]) mkdirSync(dir, { recursive: true });
|
|
85
|
+
writeExecutable(join(host.binDir, 'claude'), RUNTIME_STUB);
|
|
86
|
+
if (scheduler !== 'missing') {
|
|
87
|
+
writeExecutable(join(host.binDir, 'helm-tasks'), SCHEDULER_STUBS[scheduler]);
|
|
88
|
+
}
|
|
89
|
+
host.env = hostEnv(host);
|
|
90
|
+
return host;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Built from nothing, not from process.env. PATH carries the stub directory,
|
|
94
|
+
// the running node (Academy's launcher is `#!/usr/bin/env node`), and the system
|
|
95
|
+
// directories that hold `git`, which `doctor` uses to describe a checkout.
|
|
96
|
+
function hostEnv(host) {
|
|
97
|
+
return {
|
|
98
|
+
HOME: host.home,
|
|
99
|
+
AGENTS_ROOT: host.agentsRoot,
|
|
100
|
+
PATH: [host.binDir, dirname(process.execPath), '/usr/bin', '/bin'].join(':'),
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Run the binary under test. Returns the raw result a shell would see. */
|
|
105
|
+
export function academy(host, args, env = {}) {
|
|
106
|
+
const result = spawnSync(ACADEMY_BIN, args, {
|
|
107
|
+
cwd: host.root,
|
|
108
|
+
encoding: 'utf8',
|
|
109
|
+
env: { ...host.env, ...env },
|
|
110
|
+
});
|
|
111
|
+
if (result.error) throw result.error;
|
|
112
|
+
return { status: result.status, stdout: result.stdout ?? '', stderr: result.stderr ?? '' };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
116
|
+
// The envelope
|
|
117
|
+
//
|
|
118
|
+
// success stdout, exit 0 { contract_version, ok: true, command, ...payload }
|
|
119
|
+
// failure stderr, exit 1 { contract_version, ok: false, command,
|
|
120
|
+
// error: { code, message, ...context } }
|
|
121
|
+
//
|
|
122
|
+
// Exit status is 0 if and only if `ok` is true. `doctor` is the one command
|
|
123
|
+
// whose failure envelope also carries its payload.
|
|
124
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
125
|
+
|
|
126
|
+
function parseEnvelope(text, where, result) {
|
|
127
|
+
if (text.trim() === '') {
|
|
128
|
+
throw new Error(`expected a JSON envelope on ${where}, got nothing.\n${describe(result)}`);
|
|
129
|
+
}
|
|
130
|
+
try {
|
|
131
|
+
return JSON.parse(text);
|
|
132
|
+
} catch (error) {
|
|
133
|
+
throw new Error(`expected a JSON envelope on ${where}: ${error.message}\n${describe(result)}`);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function describe(result) {
|
|
138
|
+
return ` exit ${result.status}\n stdout ${result.stdout.trim()}\n stderr ${result.stderr.trim()}`;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function assertEqual(actual, expected, what, result) {
|
|
142
|
+
if (actual === expected) return;
|
|
143
|
+
throw new Error(
|
|
144
|
+
`expected ${what} to be ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}\n${describe(result)}`,
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Assert a success envelope on stdout with exit 0, and return its payload. */
|
|
149
|
+
export function assertSuccess(result, command) {
|
|
150
|
+
assertEqual(result.status, 0, `exit status of "${command}"`, result);
|
|
151
|
+
const envelope = parseEnvelope(result.stdout, 'stdout', result);
|
|
152
|
+
assertEqual(envelope.contract_version, CONTRACT_VERSION, 'contract_version', result);
|
|
153
|
+
assertEqual(envelope.ok, true, 'ok', result);
|
|
154
|
+
assertEqual(envelope.command, command, 'command', result);
|
|
155
|
+
return envelope;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Assert a failure envelope on stderr with a non-zero exit, and return it. */
|
|
159
|
+
export function assertFailure(result, command, code) {
|
|
160
|
+
if (result.status === 0) {
|
|
161
|
+
throw new Error(`expected "${command}" to exit non-zero\n${describe(result)}`);
|
|
162
|
+
}
|
|
163
|
+
const envelope = parseEnvelope(result.stderr, 'stderr', result);
|
|
164
|
+
assertEqual(envelope.contract_version, CONTRACT_VERSION, 'contract_version', result);
|
|
165
|
+
assertEqual(envelope.ok, false, 'ok', result);
|
|
166
|
+
assertEqual(envelope.command, command, 'command', result);
|
|
167
|
+
if (code !== undefined)
|
|
168
|
+
assertEqual(envelope.error?.code, code, `error code of "${command}"`, result);
|
|
169
|
+
return envelope;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** The command list `doctor` publishes — the discovery answer clients build on. */
|
|
173
|
+
export function publishedCommands(host) {
|
|
174
|
+
return assertSuccess(academy(host, ['doctor', '--json']), 'doctor').commands;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** Scaffold an agent through the binary under test, and fail loudly if it cannot. */
|
|
178
|
+
export function hireAgent(host, name) {
|
|
179
|
+
return assertSuccess(academy(host, ['create', name, '--json']), 'create');
|
|
180
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
// Identity containment — an agent Academy addresses is an agent inside its own
|
|
2
|
+
// agents root, and nothing a caller supplies can move that boundary.
|
|
3
|
+
//
|
|
4
|
+
// The table below is the *agent-addressed* published commands: the ones that
|
|
5
|
+
// name an agent. `doctor`, `list`, `sessions`, `events` and `migrate` are
|
|
6
|
+
// published too, but they name no agent, so this vector cannot reach them and a
|
|
7
|
+
// row for them could never pass.
|
|
8
|
+
|
|
9
|
+
import assert from 'node:assert/strict';
|
|
10
|
+
import { cpSync, readdirSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
|
|
11
|
+
import { join } from 'node:path';
|
|
12
|
+
import test from 'node:test';
|
|
13
|
+
import { academy, assertFailure, assertSuccess, createHost, hireAgent } from './harness.mjs';
|
|
14
|
+
|
|
15
|
+
const UNSAFE = 'unsafe_agent_path';
|
|
16
|
+
|
|
17
|
+
// Every published command that addresses an agent, with an invocation that
|
|
18
|
+
// reaches its agent-resolution step.
|
|
19
|
+
const AGENT_ADDRESSED = [
|
|
20
|
+
{ command: 'inspect', args: () => ['inspect', 'kai'] },
|
|
21
|
+
{ command: 'tokens', args: () => ['tokens', 'kai'] },
|
|
22
|
+
{ command: 'budget', args: () => ['budget', 'kai'] },
|
|
23
|
+
{ command: 'create', args: () => ['create', 'kai'] },
|
|
24
|
+
{ command: 'hire', args: (host) => ['hire', '--spec', join(host.root, 'kai.json')] },
|
|
25
|
+
{ command: 'rename', args: () => ['rename', 'kai', 'nova'] },
|
|
26
|
+
{ command: 'archive', args: () => ['archive', 'kai'] },
|
|
27
|
+
{ command: 'unarchive', args: () => ['unarchive', 'kai'] },
|
|
28
|
+
{ command: 'delete', args: () => ['delete', 'kai'] },
|
|
29
|
+
];
|
|
30
|
+
|
|
31
|
+
// The commands that address an agent by the slot it occupies, so a slot
|
|
32
|
+
// resolving outside the root reaches every one of them. They are asked a second
|
|
33
|
+
// time against that vector, in two groups, because the two owe different
|
|
34
|
+
// evidence: a move must not carry the root off, and a read must not publish
|
|
35
|
+
// what it found outside. `create` and `hire` answer `agent_exists` for an
|
|
36
|
+
// occupied slot and `unarchive` answers `agent_not_found` for an empty holding
|
|
37
|
+
// area, so neither reaches the containment question by this route.
|
|
38
|
+
const RELOCATING = ['rename', 'archive', 'delete'];
|
|
39
|
+
const READING = ['inspect', 'tokens', 'budget'];
|
|
40
|
+
|
|
41
|
+
function writeSpec(host) {
|
|
42
|
+
const spec = { name: 'kai', role: 'conformance subject', objective: 'exist' };
|
|
43
|
+
writeFileSync(join(host.root, 'kai.json'), JSON.stringify(spec));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// A root that is a symlink to a directory beside it, plus an ACADEMY_AGENT_DIR
|
|
47
|
+
// naming that same out-of-root directory. Both vectors at once, so a command
|
|
48
|
+
// that honoured either one would land outside the root.
|
|
49
|
+
function escapedRoot(host) {
|
|
50
|
+
const escaped = join(host.root, 'escaped-root');
|
|
51
|
+
symlinkSync(host.outside, escaped);
|
|
52
|
+
return { AGENTS_ROOT: escaped, ACADEMY_AGENT_DIR: host.outside };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
test('every agent-addressed command refuses an out-of-root root and agent directory', () => {
|
|
56
|
+
const host = createHost();
|
|
57
|
+
writeSpec(host);
|
|
58
|
+
hireAgent(host, 'kai');
|
|
59
|
+
const escaped = escapedRoot(host);
|
|
60
|
+
|
|
61
|
+
for (const { command, args } of AGENT_ADDRESSED) {
|
|
62
|
+
const result = academy(host, [...args(host), '--json'], escaped);
|
|
63
|
+
assertFailure(result, command, UNSAFE);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// The invariant, not the absence of one bug: after every refusal above,
|
|
67
|
+
// nothing was written outside the root and the agent is still addressable
|
|
68
|
+
// inside it.
|
|
69
|
+
assert.deepEqual(readdirSync(host.outside), []);
|
|
70
|
+
assertSuccess(academy(host, ['inspect', 'kai', '--json']), 'inspect');
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test('a refusal never leaves the agents root half-moved', () => {
|
|
74
|
+
const host = createHost();
|
|
75
|
+
writeSpec(host);
|
|
76
|
+
hireAgent(host, 'kai');
|
|
77
|
+
const escaped = escapedRoot(host);
|
|
78
|
+
const before = readdirSync(host.agentsRoot).sort();
|
|
79
|
+
|
|
80
|
+
for (const { args } of AGENT_ADDRESSED) academy(host, [...args(host), '--json'], escaped);
|
|
81
|
+
|
|
82
|
+
assert.deepEqual(readdirSync(host.agentsRoot).sort(), before);
|
|
83
|
+
assert.deepEqual(readdirSync(host.outside), []);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
// An agent directory carried out of the root, with its slot left behind as a
|
|
87
|
+
// symlink to it. Every command that resolves an agent by name lands on it.
|
|
88
|
+
function escapedSlot(host) {
|
|
89
|
+
const carried = join(host.outside, 'kai');
|
|
90
|
+
cpSync(join(host.agentsRoot, 'kai'), carried, { recursive: true });
|
|
91
|
+
rmSync(join(host.agentsRoot, 'kai'), { recursive: true });
|
|
92
|
+
symlinkSync(carried, join(host.agentsRoot, 'kai'));
|
|
93
|
+
return carried;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
test('a command that moves an agent refuses a slot resolving outside the root', () => {
|
|
97
|
+
const host = createHost();
|
|
98
|
+
hireAgent(host, 'kai');
|
|
99
|
+
escapedSlot(host);
|
|
100
|
+
writeFileSync(join(host.outside, 'witness'), 'untouched\n');
|
|
101
|
+
|
|
102
|
+
for (const command of RELOCATING) {
|
|
103
|
+
const args = command === 'rename' ? [command, 'kai', 'zed'] : [command, 'kai'];
|
|
104
|
+
assertFailure(academy(host, [...args, '--json']), command, UNSAFE);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
assert.deepEqual(readdirSync(host.outside).sort(), ['kai', 'witness']);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
// The same vector against the commands that only read. They write nothing, so
|
|
111
|
+
// the invariant they owe is the other one: an answer about an agent is evidence
|
|
112
|
+
// that the agent lives where Academy says it does. A success here would report
|
|
113
|
+
// content from outside the root under a `dir` field claiming to be inside it,
|
|
114
|
+
// which is why the refusal — not the payload — is what a client can trust.
|
|
115
|
+
test('a command that reads an agent refuses a slot resolving outside the root', () => {
|
|
116
|
+
const host = createHost();
|
|
117
|
+
hireAgent(host, 'kai');
|
|
118
|
+
const carried = escapedSlot(host);
|
|
119
|
+
writeFileSync(join(carried, 'role.md'), 'read from outside the agents root\n');
|
|
120
|
+
|
|
121
|
+
for (const command of READING) {
|
|
122
|
+
const envelope = assertFailure(academy(host, [command, 'kai', '--json']), command, UNSAFE);
|
|
123
|
+
assert.equal(envelope.dir, undefined, `${command} must publish no dir it did not resolve`);
|
|
124
|
+
}
|
|
125
|
+
});
|