agent-tempo 1.7.0-beta.6 → 1.7.0-beta.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/CLAUDE.md +1 -0
- package/dashboard/package.json +1 -1
- package/dist/cli/command-center-command.js +6 -4
- package/dist/cli.js +3 -0
- package/dist/pi/install.d.ts +32 -0
- package/dist/pi/install.js +43 -5
- package/dist/pi/mission-control/actions.d.ts +25 -4
- package/dist/pi/mission-control/actions.js +42 -17
- package/dist/pi/mission-control/extension.js +7 -2
- package/dist/pi/mission-control/inner-tail.d.ts +7 -1
- package/dist/pi/mission-control/inner-tail.js +10 -2
- package/package.json +1 -1
package/CLAUDE.md
CHANGED
|
@@ -104,6 +104,7 @@ src/
|
|
|
104
104
|
│ ├── hosts.ts / set-ensemble-description.ts
|
|
105
105
|
│ ├── save-state.ts / fetch-state.ts / clear-state.ts
|
|
106
106
|
│ ├── coat-check-put.ts / coat-check-get.ts / coat-check-list.ts / coat-check-evict.ts
|
|
107
|
+
│ ├── respond.ts
|
|
107
108
|
│ └── descriptor.ts # Transport-neutral tool descriptor (TempoToolDescriptor) + renderToMcp; per-tool `build*Tool` factories live in each tool file (MD-B, Phase 1)
|
|
108
109
|
├── pi/ # Pi-native integration — a Pi session as a first-class player over the Temporal core
|
|
109
110
|
│ ├── extension.ts # `export default function(pi)` — interactive runtime entry. Holds the MODULE-SCOPE singleton `Map<workflowId, PiPlayerRuntime>` that survives Pi's per-switch instance rebuild (rebind, not re-claim); full tool surface via renderToPi; Option-C reason-discriminated teardown
|
package/dashboard/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-tempo-dashboard",
|
|
3
3
|
"private": true,
|
|
4
|
-
"version": "1.7.0-beta.
|
|
4
|
+
"version": "1.7.0-beta.7",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "Web dashboard for agent-tempo. Bundled into the npm package; served by the daemon at /dashboard/*.",
|
|
7
7
|
"scripts": {
|
|
@@ -72,12 +72,14 @@ async function commandCenterCommand(args) {
|
|
|
72
72
|
process.exit(1);
|
|
73
73
|
}
|
|
74
74
|
// Admin (T3) token — mission-control's operator write/gate surface reads it.
|
|
75
|
-
//
|
|
76
|
-
//
|
|
75
|
+
// #54: a LOCAL (loopback) daemon grants full trust tokenless, so a tokenless
|
|
76
|
+
// board is fully functional locally; only a REMOTE / 0.0.0.0 daemon requires the
|
|
77
|
+
// token. Informational (not a warning, not a block) — accurate to the daemon's
|
|
78
|
+
// own auth posture.
|
|
77
79
|
const adminToken = process.env[config_1.ENV.HTTP_ADMIN_TOKEN];
|
|
78
80
|
if (!adminToken) {
|
|
79
|
-
out.
|
|
80
|
-
'
|
|
81
|
+
out.log(out.dim(` ${config_1.ENV.HTTP_ADMIN_TOKEN} not set — fine for a local (loopback) daemon (full trust). ` +
|
|
82
|
+
'Set it only if this board drives a remote / 0.0.0.0 daemon.'));
|
|
81
83
|
}
|
|
82
84
|
if (!process.env.ANTHROPIC_API_KEY) {
|
|
83
85
|
out.warn('ANTHROPIC_API_KEY is not set — the Pi command-center will fall back to Pi\'s own auth/default model.');
|
package/dist/cli.js
CHANGED
|
@@ -638,6 +638,9 @@ async function main() {
|
|
|
638
638
|
const { installPiExtensions } = await Promise.resolve().then(() => __importStar(require('./pi/install')));
|
|
639
639
|
const result = installPiExtensions({ project: args.project });
|
|
640
640
|
out.success(`Pi extensions installed → ${result.settingsPath}`);
|
|
641
|
+
// #52 — show pruned stale/old-version entries so an upgrade is legible.
|
|
642
|
+
for (const p of result.removed)
|
|
643
|
+
out.log(` ${out.yellow('-')} ${p} ${out.dim('(removed stale/old-version entry)')}`);
|
|
641
644
|
for (const p of result.added)
|
|
642
645
|
out.log(` ${out.green('+')} ${p}`);
|
|
643
646
|
for (const p of result.alreadyPresent)
|
package/dist/pi/install.d.ts
CHANGED
|
@@ -26,9 +26,34 @@ export interface InstallPiResult {
|
|
|
26
26
|
added: string[];
|
|
27
27
|
/** Extension paths already present before this run. */
|
|
28
28
|
alreadyPresent: string[];
|
|
29
|
+
/**
|
|
30
|
+
* #52 — STALE agent-tempo extension paths PRUNED by this run (old-version /
|
|
31
|
+
* moved-install entries that pointed at an agent-tempo extension but are no
|
|
32
|
+
* longer the current path). Empty on a clean re-run.
|
|
33
|
+
*/
|
|
34
|
+
removed: string[];
|
|
29
35
|
/** The final `extensions` array written to settings.json. */
|
|
30
36
|
extensions: string[];
|
|
31
37
|
}
|
|
38
|
+
/**
|
|
39
|
+
* #52 — does this settings `extensions` entry point at an agent-tempo Pi
|
|
40
|
+
* extension (player or command-center), of ANY version / install location?
|
|
41
|
+
*
|
|
42
|
+
* The motivating bug: a `pnpm` global install version-hashes the package dir
|
|
43
|
+
* (`.../.pnpm/agent-tempo@<version>_<hash>/node_modules/agent-tempo/...`), so on
|
|
44
|
+
* UPGRADE the recorded absolute path goes stale — and a naive add-only install
|
|
45
|
+
* leaves BOTH the old and new paths in `settings.json`, which makes `pi` fail on
|
|
46
|
+
* the now-missing stale entry. {@link installPiExtensions} prunes every match of
|
|
47
|
+
* this predicate (except the current paths) before re-adding, so a re-run
|
|
48
|
+
* REPLACES rather than duplicates.
|
|
49
|
+
*
|
|
50
|
+
* Match = an agent-tempo package marker (`/agent-tempo@…` version dir, or the
|
|
51
|
+
* `/node_modules/agent-tempo/` package dir) AND an agent-tempo extension suffix
|
|
52
|
+
* (`dist/pi/extension.js` or `dist/pi/mission-control/extension.js`). Both halves
|
|
53
|
+
* are required so a user's own unrelated extension is never pruned. Separators
|
|
54
|
+
* are normalised so the predicate holds on Windows paths too.
|
|
55
|
+
*/
|
|
56
|
+
export declare function isAgentTempoExtensionPath(p: string): boolean;
|
|
32
57
|
/** Resolve the Pi settings.json path for the chosen scope. */
|
|
33
58
|
export declare function piSettingsPath(opts?: InstallPiOptions): string;
|
|
34
59
|
/**
|
|
@@ -37,6 +62,13 @@ export declare function piSettingsPath(opts?: InstallPiOptions): string;
|
|
|
37
62
|
* write when nothing changed). Never copies any extension file — install by
|
|
38
63
|
* reference only (see file header).
|
|
39
64
|
*
|
|
65
|
+
* #52 — REPLACE, don't accumulate: before adding the current paths, PRUNE any
|
|
66
|
+
* STALE agent-tempo extension entries ({@link isAgentTempoExtensionPath}, minus
|
|
67
|
+
* the current paths). On a `pnpm` upgrade the package dir is version-hashed, so
|
|
68
|
+
* the recorded absolute path changes — without pruning, `settings.json` would
|
|
69
|
+
* list both the old (now-missing) and new paths and `pi` would fail on the stale
|
|
70
|
+
* one. A user's own unrelated extensions and other settings keys are preserved.
|
|
71
|
+
*
|
|
40
72
|
* Tolerates a missing / empty / corrupt settings file: a missing file is
|
|
41
73
|
* created; an unparseable one is replaced with a fresh object carrying just the
|
|
42
74
|
* extensions (we can only safely merge a valid object). Other recognised keys in
|
package/dist/pi/install.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.piExtensionPaths = piExtensionPaths;
|
|
4
|
+
exports.isAgentTempoExtensionPath = isAgentTempoExtensionPath;
|
|
4
5
|
exports.piSettingsPath = piSettingsPath;
|
|
5
6
|
exports.installPiExtensions = installPiExtensions;
|
|
6
7
|
/**
|
|
@@ -45,6 +46,30 @@ function piExtensionPaths() {
|
|
|
45
46
|
missionControl: (0, path_1.resolve)(__dirname, 'mission-control', 'extension.js'),
|
|
46
47
|
};
|
|
47
48
|
}
|
|
49
|
+
/**
|
|
50
|
+
* #52 — does this settings `extensions` entry point at an agent-tempo Pi
|
|
51
|
+
* extension (player or command-center), of ANY version / install location?
|
|
52
|
+
*
|
|
53
|
+
* The motivating bug: a `pnpm` global install version-hashes the package dir
|
|
54
|
+
* (`.../.pnpm/agent-tempo@<version>_<hash>/node_modules/agent-tempo/...`), so on
|
|
55
|
+
* UPGRADE the recorded absolute path goes stale — and a naive add-only install
|
|
56
|
+
* leaves BOTH the old and new paths in `settings.json`, which makes `pi` fail on
|
|
57
|
+
* the now-missing stale entry. {@link installPiExtensions} prunes every match of
|
|
58
|
+
* this predicate (except the current paths) before re-adding, so a re-run
|
|
59
|
+
* REPLACES rather than duplicates.
|
|
60
|
+
*
|
|
61
|
+
* Match = an agent-tempo package marker (`/agent-tempo@…` version dir, or the
|
|
62
|
+
* `/node_modules/agent-tempo/` package dir) AND an agent-tempo extension suffix
|
|
63
|
+
* (`dist/pi/extension.js` or `dist/pi/mission-control/extension.js`). Both halves
|
|
64
|
+
* are required so a user's own unrelated extension is never pruned. Separators
|
|
65
|
+
* are normalised so the predicate holds on Windows paths too.
|
|
66
|
+
*/
|
|
67
|
+
function isAgentTempoExtensionPath(p) {
|
|
68
|
+
const n = p.replace(/\\/g, '/');
|
|
69
|
+
const fromAgentTempo = n.includes('/agent-tempo@') || n.includes('/node_modules/agent-tempo/');
|
|
70
|
+
const isExtensionEntry = n.endsWith('/dist/pi/extension.js') || n.endsWith('/dist/pi/mission-control/extension.js');
|
|
71
|
+
return fromAgentTempo && isExtensionEntry;
|
|
72
|
+
}
|
|
48
73
|
/** Resolve the Pi settings.json path for the chosen scope. */
|
|
49
74
|
function piSettingsPath(opts = {}) {
|
|
50
75
|
if (opts.project)
|
|
@@ -57,6 +82,13 @@ function piSettingsPath(opts = {}) {
|
|
|
57
82
|
* write when nothing changed). Never copies any extension file — install by
|
|
58
83
|
* reference only (see file header).
|
|
59
84
|
*
|
|
85
|
+
* #52 — REPLACE, don't accumulate: before adding the current paths, PRUNE any
|
|
86
|
+
* STALE agent-tempo extension entries ({@link isAgentTempoExtensionPath}, minus
|
|
87
|
+
* the current paths). On a `pnpm` upgrade the package dir is version-hashed, so
|
|
88
|
+
* the recorded absolute path changes — without pruning, `settings.json` would
|
|
89
|
+
* list both the old (now-missing) and new paths and `pi` would fail on the stale
|
|
90
|
+
* one. A user's own unrelated extensions and other settings keys are preserved.
|
|
91
|
+
*
|
|
60
92
|
* Tolerates a missing / empty / corrupt settings file: a missing file is
|
|
61
93
|
* created; an unparseable one is replaced with a fresh object carrying just the
|
|
62
94
|
* extensions (we can only safely merge a valid object). Other recognised keys in
|
|
@@ -84,9 +116,15 @@ function installPiExtensions(opts = {}) {
|
|
|
84
116
|
const current = Array.isArray(settings.extensions)
|
|
85
117
|
? settings.extensions.filter((x) => typeof x === 'string')
|
|
86
118
|
: [];
|
|
119
|
+
// #52 — prune STALE agent-tempo extension entries (an agent-tempo extension
|
|
120
|
+
// path that is NOT one of the current `want` paths — e.g. an old version-hashed
|
|
121
|
+
// pnpm dir). The current paths and all non-agent-tempo entries keep their
|
|
122
|
+
// original positions.
|
|
123
|
+
const removed = current.filter((p) => !want.includes(p) && isAgentTempoExtensionPath(p));
|
|
124
|
+
const removedSet = new Set(removed);
|
|
87
125
|
const added = [];
|
|
88
126
|
const alreadyPresent = [];
|
|
89
|
-
const merged =
|
|
127
|
+
const merged = current.filter((p) => !removedSet.has(p));
|
|
90
128
|
for (const p of want) {
|
|
91
129
|
if (merged.includes(p)) {
|
|
92
130
|
alreadyPresent.push(p);
|
|
@@ -97,11 +135,11 @@ function installPiExtensions(opts = {}) {
|
|
|
97
135
|
}
|
|
98
136
|
}
|
|
99
137
|
settings.extensions = merged;
|
|
100
|
-
// Idempotent: only write when something actually changed (or the
|
|
101
|
-
// absent and must be created). A clean repeat run touches nothing.
|
|
102
|
-
if (added.length > 0 || !fileExists) {
|
|
138
|
+
// Idempotent: only write when something actually changed (added, pruned, or the
|
|
139
|
+
// file is absent and must be created). A clean repeat run touches nothing.
|
|
140
|
+
if (added.length > 0 || removed.length > 0 || !fileExists) {
|
|
103
141
|
(0, fs_1.mkdirSync)((0, path_1.dirname)(settingsPath), { recursive: true });
|
|
104
142
|
(0, fs_1.writeFileSync)(settingsPath, `${JSON.stringify(settings, null, 2)}\n`, 'utf8');
|
|
105
143
|
}
|
|
106
|
-
return { settingsPath, added, alreadyPresent, extensions: merged };
|
|
144
|
+
return { settingsPath, added, alreadyPresent, removed, extensions: merged };
|
|
107
145
|
}
|
|
@@ -32,14 +32,35 @@ export declare class MissionControlActions {
|
|
|
32
32
|
private readonly baseUrlOverride;
|
|
33
33
|
private readonly fetchFn;
|
|
34
34
|
constructor(opts: MissionControlActionsOptions);
|
|
35
|
-
/**
|
|
35
|
+
/**
|
|
36
|
+
* Whether the client has a usable transport. (#54) NO LONGER gates on the admin
|
|
37
|
+
* token: a loopback daemon grants full trust tokenless, so token presence does
|
|
38
|
+
* NOT determine usability — the daemon decides per request. Token-required is
|
|
39
|
+
* enforced by the daemon (it 401s a remote/0.0.0.0 caller), not pre-empted here.
|
|
40
|
+
*/
|
|
36
41
|
get ready(): boolean;
|
|
37
42
|
private baseUrl;
|
|
43
|
+
/**
|
|
44
|
+
* Request headers — include the admin bearer ONLY when a token is set (#54). A
|
|
45
|
+
* loopback daemon grants full trust tokenless (it short-circuits all tiers), so
|
|
46
|
+
* we attempt tokenless and never send a literal "Bearer undefined". Mirrors how
|
|
47
|
+
* `createSubscribe` already spreads its token only when present.
|
|
48
|
+
*/
|
|
49
|
+
private authHeaders;
|
|
50
|
+
/**
|
|
51
|
+
* Map a non-2xx daemon response to an error string (#54). When NO token was sent
|
|
52
|
+
* and the daemon rejected on auth (401/403) or admin-unset (503), the cause is a
|
|
53
|
+
* remote / `0.0.0.0` daemon that requires the admin token — surface that
|
|
54
|
+
* actionably (a local loopback daemon needs none). Token-present failures keep
|
|
55
|
+
* the daemon's own body detail (it already returns good 403/503 hints).
|
|
56
|
+
*/
|
|
57
|
+
private httpError;
|
|
38
58
|
private post;
|
|
39
|
-
/** POST and parse a JSON response body
|
|
40
|
-
*
|
|
59
|
+
/** POST and parse a JSON response body. Used when the caller needs the response
|
|
60
|
+
* payload, not just success — e.g. the coat-check ticket. Bearer iff token set (#54). */
|
|
41
61
|
private postJson;
|
|
42
|
-
/** GET a JSON body from the daemon
|
|
62
|
+
/** GET a JSON body from the daemon. Used by the read surface (#700 readAnswer).
|
|
63
|
+
* Bearer iff token set (#54). */
|
|
43
64
|
private getJson;
|
|
44
65
|
private ens;
|
|
45
66
|
private player;
|
|
@@ -29,9 +29,14 @@ class MissionControlActions {
|
|
|
29
29
|
this.baseUrlOverride = opts.baseUrl;
|
|
30
30
|
this.fetchFn = opts.fetchFn ?? resolveFetch();
|
|
31
31
|
}
|
|
32
|
-
/**
|
|
32
|
+
/**
|
|
33
|
+
* Whether the client has a usable transport. (#54) NO LONGER gates on the admin
|
|
34
|
+
* token: a loopback daemon grants full trust tokenless, so token presence does
|
|
35
|
+
* NOT determine usability — the daemon decides per request. Token-required is
|
|
36
|
+
* enforced by the daemon (it 401s a remote/0.0.0.0 caller), not pre-empted here.
|
|
37
|
+
*/
|
|
33
38
|
get ready() {
|
|
34
|
-
return
|
|
39
|
+
return this.fetchFn !== null;
|
|
35
40
|
}
|
|
36
41
|
baseUrl() {
|
|
37
42
|
if (this.baseUrlOverride)
|
|
@@ -39,9 +44,32 @@ class MissionControlActions {
|
|
|
39
44
|
const port = (0, port_file_1.readPortFile)() ?? DEFAULT_PORT;
|
|
40
45
|
return `http://127.0.0.1:${port}`;
|
|
41
46
|
}
|
|
47
|
+
/**
|
|
48
|
+
* Request headers — include the admin bearer ONLY when a token is set (#54). A
|
|
49
|
+
* loopback daemon grants full trust tokenless (it short-circuits all tiers), so
|
|
50
|
+
* we attempt tokenless and never send a literal "Bearer undefined". Mirrors how
|
|
51
|
+
* `createSubscribe` already spreads its token only when present.
|
|
52
|
+
*/
|
|
53
|
+
authHeaders(extra = {}) {
|
|
54
|
+
return { ...extra, ...(this.adminToken ? { Authorization: `Bearer ${this.adminToken}` } : {}) };
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Map a non-2xx daemon response to an error string (#54). When NO token was sent
|
|
58
|
+
* and the daemon rejected on auth (401/403) or admin-unset (503), the cause is a
|
|
59
|
+
* remote / `0.0.0.0` daemon that requires the admin token — surface that
|
|
60
|
+
* actionably (a local loopback daemon needs none). Token-present failures keep
|
|
61
|
+
* the daemon's own body detail (it already returns good 403/503 hints).
|
|
62
|
+
*/
|
|
63
|
+
httpError(status, detail) {
|
|
64
|
+
if (!this.adminToken && (status === 401 || status === 403 || status === 503)) {
|
|
65
|
+
return (`HTTP ${status}: operator actions need ${exports.ADMIN_TOKEN_ENV} for a remote / 0.0.0.0 daemon ` +
|
|
66
|
+
`(a local loopback daemon needs none)${detail ? ` — ${detail}` : ''}`);
|
|
67
|
+
}
|
|
68
|
+
return `HTTP ${status}${detail ? `: ${detail}` : ''}`;
|
|
69
|
+
}
|
|
42
70
|
async post(pathSuffix, body) {
|
|
43
|
-
|
|
44
|
-
|
|
71
|
+
// #54 — do NOT pre-block on a missing token: attempt the request and let the
|
|
72
|
+
// daemon decide (loopback grants full trust tokenless; remote/0.0.0.0 401s).
|
|
45
73
|
if (!this.fetchFn)
|
|
46
74
|
return { ok: false, error: 'no fetch transport available' };
|
|
47
75
|
const base = this.baseUrl();
|
|
@@ -50,23 +78,21 @@ class MissionControlActions {
|
|
|
50
78
|
try {
|
|
51
79
|
const res = await this.fetchFn(`${base}${pathSuffix}`, {
|
|
52
80
|
method: 'POST',
|
|
53
|
-
headers:
|
|
81
|
+
headers: this.authHeaders({ 'Content-Type': 'application/json' }),
|
|
54
82
|
body: JSON.stringify(body ?? {}),
|
|
55
83
|
});
|
|
56
84
|
if (res.status >= 200 && res.status < 300)
|
|
57
85
|
return { ok: true, status: res.status };
|
|
58
86
|
const detail = (await res.text().catch(() => '')).slice(0, 200);
|
|
59
|
-
return { ok: false, error:
|
|
87
|
+
return { ok: false, error: this.httpError(res.status, detail) };
|
|
60
88
|
}
|
|
61
89
|
catch (err) {
|
|
62
90
|
return { ok: false, error: err instanceof Error ? err.message : String(err) };
|
|
63
91
|
}
|
|
64
92
|
}
|
|
65
|
-
/** POST and parse a JSON response body
|
|
66
|
-
*
|
|
93
|
+
/** POST and parse a JSON response body. Used when the caller needs the response
|
|
94
|
+
* payload, not just success — e.g. the coat-check ticket. Bearer iff token set (#54). */
|
|
67
95
|
async postJson(pathSuffix, body) {
|
|
68
|
-
if (!this.adminToken)
|
|
69
|
-
return { ok: false, error: `no admin token (set ${exports.ADMIN_TOKEN_ENV})` };
|
|
70
96
|
if (!this.fetchFn)
|
|
71
97
|
return { ok: false, error: 'no fetch transport available' };
|
|
72
98
|
const base = this.baseUrl();
|
|
@@ -75,22 +101,21 @@ class MissionControlActions {
|
|
|
75
101
|
try {
|
|
76
102
|
const res = await this.fetchFn(`${base}${pathSuffix}`, {
|
|
77
103
|
method: 'POST',
|
|
78
|
-
headers:
|
|
104
|
+
headers: this.authHeaders({ 'Content-Type': 'application/json' }),
|
|
79
105
|
body: JSON.stringify(body ?? {}),
|
|
80
106
|
});
|
|
81
107
|
const text = await res.text().catch(() => '');
|
|
82
108
|
if (res.status < 200 || res.status >= 300)
|
|
83
|
-
return { ok: false, error:
|
|
109
|
+
return { ok: false, error: this.httpError(res.status, text.slice(0, 200)) };
|
|
84
110
|
return { ok: true, data: JSON.parse(text) };
|
|
85
111
|
}
|
|
86
112
|
catch (err) {
|
|
87
113
|
return { ok: false, error: err instanceof Error ? err.message : String(err) };
|
|
88
114
|
}
|
|
89
115
|
}
|
|
90
|
-
/** GET a JSON body from the daemon
|
|
116
|
+
/** GET a JSON body from the daemon. Used by the read surface (#700 readAnswer).
|
|
117
|
+
* Bearer iff token set (#54). */
|
|
91
118
|
async getJson(pathSuffix) {
|
|
92
|
-
if (!this.adminToken)
|
|
93
|
-
return { ok: false, error: `no admin token (set ${exports.ADMIN_TOKEN_ENV})` };
|
|
94
119
|
if (!this.fetchFn)
|
|
95
120
|
return { ok: false, error: 'no fetch transport available' };
|
|
96
121
|
const base = this.baseUrl();
|
|
@@ -99,11 +124,11 @@ class MissionControlActions {
|
|
|
99
124
|
try {
|
|
100
125
|
const res = await this.fetchFn(`${base}${pathSuffix}`, {
|
|
101
126
|
method: 'GET',
|
|
102
|
-
headers:
|
|
127
|
+
headers: this.authHeaders(),
|
|
103
128
|
});
|
|
104
129
|
const text = await res.text().catch(() => '');
|
|
105
130
|
if (res.status < 200 || res.status >= 300)
|
|
106
|
-
return { ok: false, error:
|
|
131
|
+
return { ok: false, error: this.httpError(res.status, text.slice(0, 200)) };
|
|
107
132
|
return { ok: true, data: JSON.parse(text) };
|
|
108
133
|
}
|
|
109
134
|
catch (err) {
|
|
@@ -564,8 +564,11 @@ function createMissionControlExtension(deps = {}) {
|
|
|
564
564
|
activeCtx.ui.setWidget(WIDGET_KEY, (0, render_1.renderBoard)(ctrl.model, ctrl.localHost), { placement: 'aboveEditor' });
|
|
565
565
|
};
|
|
566
566
|
const startCoarse = () => {
|
|
567
|
+
// #54 — accurate posture: a tokenless board is FULLY functional against a
|
|
568
|
+
// local (loopback) daemon, which grants full trust. Only a REMOTE / 0.0.0.0
|
|
569
|
+
// daemon requires the admin token (it 401s tokenless reads + actions).
|
|
567
570
|
if (!adminToken) {
|
|
568
|
-
log(`no
|
|
571
|
+
log(`no ${actions_1.ADMIN_TOKEN_ENV} — OK for a local loopback daemon (full trust); a remote / 0.0.0.0 daemon will require it`);
|
|
569
572
|
}
|
|
570
573
|
// H5: capture the controller locally. teardown nulls the outer `coarseAbort`,
|
|
571
574
|
// so the catch must check THIS signal — checking the nulled outer ref made an
|
|
@@ -604,7 +607,9 @@ function createMissionControlExtension(deps = {}) {
|
|
|
604
607
|
const openTail = (playerId) => {
|
|
605
608
|
tailAbort?.abort();
|
|
606
609
|
tailAbort = null;
|
|
607
|
-
|
|
610
|
+
// #54 — do NOT gate on the token: the loopback daemon serves /inner tokenless.
|
|
611
|
+
// openInnerTail sends the bearer iff present; a remote/0.0.0.0 daemon 401s → onError.
|
|
612
|
+
if (playerId === null)
|
|
608
613
|
return;
|
|
609
614
|
tailAbort = new AbortController();
|
|
610
615
|
// H5: resolve the daemon base URL HERE (per /tail) so a port change is
|
|
@@ -32,7 +32,13 @@ export type TailFetch = (url: string, init: {
|
|
|
32
32
|
}>;
|
|
33
33
|
export interface OpenInnerTailOptions {
|
|
34
34
|
baseUrl: string;
|
|
35
|
-
|
|
35
|
+
/**
|
|
36
|
+
* Admin (T3) token. OPTIONAL (#54): a loopback daemon serves the `/inner`
|
|
37
|
+
* egress tokenless (full-trust short-circuit). When absent, no `Authorization`
|
|
38
|
+
* header is sent and the daemon decides — a remote / `0.0.0.0` daemon 401s
|
|
39
|
+
* (surfaced via `onError`).
|
|
40
|
+
*/
|
|
41
|
+
adminToken?: string;
|
|
36
42
|
ensemble: string;
|
|
37
43
|
playerId: string;
|
|
38
44
|
onFrame: (frame: InnerFrame) => void;
|
|
@@ -44,7 +44,11 @@ async function openInnerTail(opts) {
|
|
|
44
44
|
try {
|
|
45
45
|
res = await opts.fetchFn(url, {
|
|
46
46
|
method: 'GET',
|
|
47
|
-
headers: {
|
|
47
|
+
headers: {
|
|
48
|
+
Accept: 'text/event-stream',
|
|
49
|
+
// #54 — bearer ONLY when a token is set; loopback serves tokenless.
|
|
50
|
+
...(opts.adminToken ? { Authorization: `Bearer ${opts.adminToken}` } : {}),
|
|
51
|
+
},
|
|
48
52
|
signal: opts.signal,
|
|
49
53
|
});
|
|
50
54
|
}
|
|
@@ -54,7 +58,11 @@ async function openInnerTail(opts) {
|
|
|
54
58
|
return;
|
|
55
59
|
}
|
|
56
60
|
if (res.status !== 200 || !res.body) {
|
|
57
|
-
|
|
61
|
+
// #54 — a tokenless 401/403 means a remote / 0.0.0.0 daemon that needs the token.
|
|
62
|
+
const hint = !opts.adminToken && (res.status === 401 || res.status === 403)
|
|
63
|
+
? ' (set AGENT_TEMPO_HTTP_ADMIN_TOKEN for a remote/0.0.0.0 daemon; loopback needs none)'
|
|
64
|
+
: '';
|
|
65
|
+
opts.onError?.(`inner tail HTTP ${res.status}${hint}`);
|
|
58
66
|
return;
|
|
59
67
|
}
|
|
60
68
|
const decoder = new TextDecoder();
|