instar 1.3.101 → 1.3.103
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/dist/commands/gate.d.ts.map +1 -1
- package/dist/commands/gate.js +8 -1
- package/dist/commands/gate.js.map +1 -1
- package/dist/commands/nuke.d.ts.map +1 -1
- package/dist/commands/nuke.js +19 -10
- package/dist/commands/nuke.js.map +1 -1
- package/dist/commands/server.d.ts.map +1 -1
- package/dist/commands/server.js +32 -0
- package/dist/commands/server.js.map +1 -1
- package/dist/core/PeerPresencePuller.d.ts +71 -0
- package/dist/core/PeerPresencePuller.d.ts.map +1 -0
- package/dist/core/PeerPresencePuller.js +59 -0
- package/dist/core/PeerPresencePuller.js.map +1 -0
- package/dist/core/PostUpdateMigrator.d.ts +33 -0
- package/dist/core/PostUpdateMigrator.d.ts.map +1 -1
- package/dist/core/PostUpdateMigrator.js +155 -20
- package/dist/core/PostUpdateMigrator.js.map +1 -1
- package/dist/threadline/listener-daemon.d.ts.map +1 -1
- package/dist/threadline/listener-daemon.js +18 -8
- package/dist/threadline/listener-daemon.js.map +1 -1
- package/dist/threadline/mcp-stdio-entry.js +8 -3
- package/dist/threadline/mcp-stdio-entry.js.map +1 -1
- package/package.json +1 -1
- package/scripts/threadline-bridge-backfill.mjs +7 -1
- package/src/data/builtin-manifest.json +29 -29
- package/src/templates/hooks/compaction-recovery.sh +24 -9
- package/src/templates/hooks/session-start.sh +9 -2
- package/src/templates/hooks/slack-channel-context.sh +8 -1
- package/src/templates/hooks/telegram-topic-context.sh +14 -2
- package/src/templates/scripts/imessage-reply.sh +7 -3
- package/src/templates/scripts/instar-watchdog.sh +14 -5
- package/src/templates/scripts/secret-drop-retrieve.mjs +12 -4
- package/src/templates/scripts/serendipity-capture.sh +15 -9
- package/src/templates/scripts/slack-reply.sh +8 -1
- package/src/templates/scripts/telegram-reply.sh +11 -5
- package/src/templates/scripts/whatsapp-reply.sh +7 -4
- package/upgrades/1.3.102.md +56 -0
- package/upgrades/1.3.103.md +77 -0
- package/upgrades/NEXT.eli16.md +71 -0
- package/upgrades/side-effects/http-presence-transport.md +75 -0
- package/upgrades/side-effects/secret-externalization-hook-resolver-audit.md +48 -0
|
@@ -42,9 +42,13 @@ fi
|
|
|
42
42
|
|
|
43
43
|
PORT="${INSTAR_PORT:-4042}"
|
|
44
44
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
45
|
+
# Auth: env first (SessionManager injects it per spawned session and it
|
|
46
|
+
# survives secret-externalization), legacy plaintext-config fallback with
|
|
47
|
+
# string-type guard so the { "secret": true } placeholder produced by
|
|
48
|
+
# SecretMigrator cannot leak through as a bogus Bearer.
|
|
49
|
+
AUTH_TOKEN="${INSTAR_AUTH_TOKEN:-}"
|
|
50
|
+
if [ -z "$AUTH_TOKEN" ] && [ -f ".instar/config.json" ]; then
|
|
51
|
+
AUTH_TOKEN=$(python3 -c "import json; v=json.load(open('.instar/config.json')).get('authToken',''); print(v if isinstance(v, str) else '')" 2>/dev/null)
|
|
48
52
|
fi
|
|
49
53
|
|
|
50
54
|
# Escape message for JSON
|
|
@@ -324,7 +324,11 @@ escalate_via_peer() {
|
|
|
324
324
|
node_bin=$(resolve_node || true)
|
|
325
325
|
[ -z "$node_bin" ] && continue
|
|
326
326
|
local peer_meta
|
|
327
|
-
|
|
327
|
+
# The string-type guard on authToken protects against the { secret: true }
|
|
328
|
+
# placeholder that SecretMigrator writes after externalizing the auth token:
|
|
329
|
+
# `c.authToken || ''` would otherwise return the object itself (truthy)
|
|
330
|
+
# which serializes to "[object Object]" — never a valid Bearer.
|
|
331
|
+
peer_meta=$("$node_bin" -e "const c=JSON.parse(require('fs').readFileSync(process.argv[1],'utf8'));const t=typeof c.authToken==='string'?c.authToken:'';process.stdout.write((c.port||'')+'\t'+t)" "$peer_dir/.instar/config.json" 2>/dev/null || true)
|
|
328
332
|
peer_port="${peer_meta%% *}"
|
|
329
333
|
peer_auth="${peer_meta##* }"
|
|
330
334
|
[ -z "$peer_port" ] && continue
|
|
@@ -434,11 +438,16 @@ probe_server_identity() {
|
|
|
434
438
|
node_bin=$(resolve_node || true)
|
|
435
439
|
[ -z "$node_bin" ] && return 0
|
|
436
440
|
|
|
437
|
-
# Read port + authToken in one node invocation. The authToken is
|
|
438
|
-
# because /health gates the `project` field behind authentication
|
|
439
|
-
# it the probe can
|
|
441
|
+
# Read port + authToken in one node invocation. The authToken is needed
|
|
442
|
+
# because /health gates the `project` field behind authentication; without
|
|
443
|
+
# it the probe can still hit /health for a basic alive check but loses the
|
|
444
|
+
# identity disambiguation. INSTAR_AUTH_TOKEN env is preferred when set
|
|
445
|
+
# (preserves cross-version compatibility). The string-type guard rejects the
|
|
446
|
+
# { secret: true } placeholder SecretMigrator writes after externalizing the
|
|
447
|
+
# auth token — `String(c.authToken || '')` would otherwise yield
|
|
448
|
+
# "[object Object]" and the Bearer would be invalid.
|
|
440
449
|
local meta
|
|
441
|
-
meta=$("$node_bin" -e "const c=JSON.parse(require('fs').readFileSync(process.argv[1],'utf8'));process.stdout.write(String(c.port||'')+'\t'+
|
|
450
|
+
meta=$("$node_bin" -e "const c=JSON.parse(require('fs').readFileSync(process.argv[1],'utf8'));const t=process.env.INSTAR_AUTH_TOKEN||(typeof c.authToken==='string'?c.authToken:'');process.stdout.write(String(c.port||'')+'\t'+t)" "$config_file" 2>/dev/null || true)
|
|
442
451
|
port="${meta%% *}"
|
|
443
452
|
auth_token="${meta##* }"
|
|
444
453
|
# Lifeline-only agents have no port; treat as healthy from probe POV.
|
|
@@ -47,15 +47,23 @@ if (!token || !mode || mode === '--consume') {
|
|
|
47
47
|
process.exit(2);
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
-
|
|
50
|
+
// Auth-token resolution: INSTAR_AUTH_TOKEN env first (SessionManager and
|
|
51
|
+
// JobScheduler inject it for every spawned context; survives the
|
|
52
|
+
// secret-externalization refactor that moved authToken out of config.json into
|
|
53
|
+
// the encrypted store), legacy plaintext-config fallback with a string-type
|
|
54
|
+
// guard so the { "secret": true } placeholder produced by SecretMigrator
|
|
55
|
+
// cannot leak through as a bogus Bearer.
|
|
56
|
+
let authToken = process.env.INSTAR_AUTH_TOKEN || '';
|
|
51
57
|
let port;
|
|
52
58
|
try {
|
|
53
59
|
const config = JSON.parse(fs.readFileSync('.instar/config.json', 'utf-8'));
|
|
54
|
-
authToken = config.authToken;
|
|
60
|
+
if (!authToken && typeof config.authToken === 'string') authToken = config.authToken;
|
|
55
61
|
port = config.port;
|
|
56
62
|
} catch (e) {
|
|
57
|
-
|
|
58
|
-
|
|
63
|
+
if (!authToken) {
|
|
64
|
+
process.stderr.write('cannot read .instar/config.json: ' + e.message + '\n');
|
|
65
|
+
process.exit(2);
|
|
66
|
+
}
|
|
59
67
|
}
|
|
60
68
|
|
|
61
69
|
// Port resolution: env > config. Mirrors the telegram-reply.sh precedence
|
|
@@ -294,15 +294,21 @@ if patch_file or artifact_type:
|
|
|
294
294
|
# Key derivation: HMAC-SHA256(authToken, "serendipity-v1:" + sessionId)
|
|
295
295
|
signing_key = os.environ.get("SERENDIPITY_SIGNING_KEY", "")
|
|
296
296
|
if not signing_key:
|
|
297
|
-
# Derive from auth token + session
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
297
|
+
# Derive from auth token + session. Token: INSTAR_AUTH_TOKEN env first
|
|
298
|
+
# (SessionManager injects it; survives secret-externalization), legacy
|
|
299
|
+
# plaintext-config fallback with a string-type guard so the
|
|
300
|
+
# { "secret": true } placeholder produced by SecretMigrator cannot leak.
|
|
301
|
+
auth_token = os.environ.get("INSTAR_AUTH_TOKEN", "")
|
|
302
|
+
if not auth_token:
|
|
303
|
+
config_path = os.environ.get("CONFIG_FILE", "")
|
|
304
|
+
if config_path and os.path.exists(config_path):
|
|
305
|
+
try:
|
|
306
|
+
cfg = json.load(open(config_path))
|
|
307
|
+
v = cfg.get("authToken", "")
|
|
308
|
+
if isinstance(v, str):
|
|
309
|
+
auth_token = v
|
|
310
|
+
except:
|
|
311
|
+
pass
|
|
306
312
|
session_id = finding["source"]["sessionId"]
|
|
307
313
|
key_material = f"serendipity-v1:{session_id}"
|
|
308
314
|
signing_key = hmac.new(
|
|
@@ -44,7 +44,14 @@ if [ -f ".instar/config.json" ]; then
|
|
|
44
44
|
CONFIG_PORT=$(python3 -c "import json; print(json.load(open('.instar/config.json')).get('port',''))" 2>/dev/null)
|
|
45
45
|
[ -n "$CONFIG_PORT" ] && PORT="$CONFIG_PORT"
|
|
46
46
|
fi
|
|
47
|
-
|
|
47
|
+
# Auth: env first (SessionManager injects it into spawned sessions; survives
|
|
48
|
+
# secret-externalization), legacy plaintext-config fallback with string-type
|
|
49
|
+
# guard so the { "secret": true } placeholder produced by SecretMigrator
|
|
50
|
+
# cannot leak as a bogus Bearer.
|
|
51
|
+
AUTH="${INSTAR_AUTH_TOKEN:-}"
|
|
52
|
+
if [ -z "$AUTH" ]; then
|
|
53
|
+
AUTH=$(python3 -c "import json; v=json.load(open('.instar/config.json')).get('authToken',''); print(v if isinstance(v, str) else '')" 2>/dev/null)
|
|
54
|
+
fi
|
|
48
55
|
fi
|
|
49
56
|
|
|
50
57
|
PORT="${PORT:-4042}"
|
|
@@ -75,9 +75,13 @@ if [ -z "$MSG" ]; then
|
|
|
75
75
|
fi
|
|
76
76
|
|
|
77
77
|
# Resolve config-derived values from .instar/config.json (single python3
|
|
78
|
-
# invocation). Env > config > 4040-warn for port
|
|
79
|
-
#
|
|
80
|
-
|
|
78
|
+
# invocation). Env > config > 4040-warn for port. Auth: INSTAR_AUTH_TOKEN env
|
|
79
|
+
# first (SessionManager injects it per spawned session; survives the
|
|
80
|
+
# secret-externalization refactor that moved authToken out of config.json into
|
|
81
|
+
# the encrypted store), legacy plaintext-config fallback with a string-type
|
|
82
|
+
# guard so the { "secret": true } placeholder produced by SecretMigrator
|
|
83
|
+
# cannot leak through as a bogus Bearer.
|
|
84
|
+
AUTH_TOKEN="${INSTAR_AUTH_TOKEN:-}"
|
|
81
85
|
AGENT_ID=""
|
|
82
86
|
CONFIG_PORT=""
|
|
83
87
|
if [ -f ".instar/config.json" ]; then
|
|
@@ -87,11 +91,13 @@ try:
|
|
|
87
91
|
c = json.load(open('.instar/config.json'))
|
|
88
92
|
except Exception:
|
|
89
93
|
sys.exit(0)
|
|
90
|
-
|
|
94
|
+
v = c.get('authToken', '')
|
|
95
|
+
print(v if isinstance(v, str) else '')
|
|
91
96
|
print(c.get('projectName', ''))
|
|
92
97
|
print(c.get('port', ''))
|
|
93
98
|
" 2>/dev/null)
|
|
94
|
-
|
|
99
|
+
CONFIG_AUTH=$(printf '%s\n' "$CONFIG_VALUES" | sed -n '1p')
|
|
100
|
+
[ -z "$AUTH_TOKEN" ] && AUTH_TOKEN="$CONFIG_AUTH"
|
|
95
101
|
AGENT_ID=$(printf '%s\n' "$CONFIG_VALUES" | sed -n '2p')
|
|
96
102
|
CONFIG_PORT=$(printf '%s\n' "$CONFIG_VALUES" | sed -n '3p')
|
|
97
103
|
fi
|
|
@@ -33,10 +33,13 @@ fi
|
|
|
33
33
|
|
|
34
34
|
PORT="${INSTAR_PORT:-4040}"
|
|
35
35
|
|
|
36
|
-
# Read auth token
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
36
|
+
# Read auth token: env first (SessionManager injects it per spawned session and
|
|
37
|
+
# it survives secret-externalization), legacy plaintext-config fallback with
|
|
38
|
+
# string-type guard so the { "secret": true } placeholder produced by
|
|
39
|
+
# SecretMigrator cannot leak through as a bogus Bearer.
|
|
40
|
+
AUTH_TOKEN="${INSTAR_AUTH_TOKEN:-}"
|
|
41
|
+
if [ -z "$AUTH_TOKEN" ] && [ -f ".instar/config.json" ]; then
|
|
42
|
+
AUTH_TOKEN=$(python3 -c "import json; v=json.load(open('.instar/config.json')).get('authToken',''); print(v if isinstance(v, str) else '')" 2>/dev/null)
|
|
40
43
|
fi
|
|
41
44
|
|
|
42
45
|
# Escape for JSON
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# Upgrade Guide — vNEXT
|
|
2
|
+
|
|
3
|
+
<!-- bump: patch -->
|
|
4
|
+
|
|
5
|
+
## What Changed
|
|
6
|
+
|
|
7
|
+
**Multi-machine: a second machine that can't push to your shared agent repo now
|
|
8
|
+
still shows up as online — so work can move to it.** When you run one agent
|
|
9
|
+
across two machines, the pool decides which machine is "online" (and therefore
|
|
10
|
+
eligible to receive a conversation) from a heartbeat. Until now that heartbeat
|
|
11
|
+
travelled through the shared agent git repo: each machine wrote its heartbeat
|
|
12
|
+
into the repo and the others read it. A second machine paired over the network
|
|
13
|
+
but signed into a *different* GitHub account has no way to push to that repo, so
|
|
14
|
+
its heartbeat never arrived — the main machine saw it as permanently offline and
|
|
15
|
+
refused to hand any session to it, even though the two machines could reach each
|
|
16
|
+
other perfectly over their tunnels.
|
|
17
|
+
|
|
18
|
+
There's now a direct presence channel that doesn't touch git: each machine
|
|
19
|
+
periodically asks every reachable peer "are you there?" over the same signed,
|
|
20
|
+
encrypted machine-to-machine channel it already uses for everything else, and
|
|
21
|
+
marks a peer online the moment it answers. A credential-less standby therefore
|
|
22
|
+
becomes a real, transfer-eligible member of the pool purely over its tunnel.
|
|
23
|
+
|
|
24
|
+
## What to Tell Your User
|
|
25
|
+
|
|
26
|
+
If you only run on one machine, nothing changes. If you run across two machines
|
|
27
|
+
and the second one is signed into a different GitHub account (or otherwise can't
|
|
28
|
+
push to the shared repo), it will now correctly show as online in your Machines
|
|
29
|
+
list and become eligible to receive conversations — no extra setup, and no git
|
|
30
|
+
credentials needed on the second machine.
|
|
31
|
+
|
|
32
|
+
## Summary of New Capabilities
|
|
33
|
+
|
|
34
|
+
- `PeerPresencePuller` (new core module): a pull-based HTTP presence channel.
|
|
35
|
+
Each mesh machine asks every reachable peer for its self-capacity over the
|
|
36
|
+
signed `session-status` MeshRpc command and records the answer into its local
|
|
37
|
+
`MachinePoolRegistry` — so a peer that can't push a git-synced heartbeat still
|
|
38
|
+
goes online over its tunnel. Symmetric, additive, and idempotent with the
|
|
39
|
+
existing git-synced heartbeat path; inert on a single-machine agent.
|
|
40
|
+
|
|
41
|
+
## Evidence
|
|
42
|
+
|
|
43
|
+
- `tests/unit/peer-presence-puller.test.ts` — loop semantics with injected fakes
|
|
44
|
+
(records an answering peer; never polls self; skips a peer with no URL; a
|
|
45
|
+
rejecting fetch neither throws nor records; null is not recorded; clock
|
|
46
|
+
fallback; `listPeers` re-read each pass).
|
|
47
|
+
- `tests/unit/peer-presence-wiring.test.ts` — server-boot wiring: a real puller
|
|
48
|
+
is constructed, feeds the signed `session-status` call into
|
|
49
|
+
`machinePoolRegistry.recordHeartbeat`, and is actually started (immediate pass
|
|
50
|
+
+ recurring unref-ed timer).
|
|
51
|
+
- `tests/integration/peer-presence-roundtrip.test.ts` — full chain over a real
|
|
52
|
+
loopback `/mesh/rpc` with real Ed25519 keys: a peer starts OFFLINE +
|
|
53
|
+
placement-ineligible on the router (no git heartbeat) and, after ONE presence
|
|
54
|
+
pass over the signed channel, is ONLINE + placement-eligible — with an
|
|
55
|
+
unreachable peer never marked online.
|
|
56
|
+
- Side-effects: `upgrades/side-effects/http-presence-transport.md`.
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
---
|
|
2
|
+
review-convergence: complete
|
|
3
|
+
approved: true
|
|
4
|
+
approved-by: justin (verbal, topic 2169: "yes! then we need to do a full, robust audit to fix this whole class of issues since it keeps coming up")
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# Upgrade Guide — vNEXT
|
|
8
|
+
|
|
9
|
+
<!-- bump: patch -->
|
|
10
|
+
|
|
11
|
+
## What Changed
|
|
12
|
+
|
|
13
|
+
**Fleet-wide fix for the silent-403 class.** Every shipped hook, script, Node
|
|
14
|
+
helper, and migrator-emitted template that resolves the agent's `authToken`
|
|
15
|
+
now survives the secret-externalization refactor. Two related bugs landed
|
|
16
|
+
together because both produced the same user-visible symptom (a hook that
|
|
17
|
+
stops emitting any output):
|
|
18
|
+
|
|
19
|
+
- **Auth-token resolution: env-first with a string-type guard.**
|
|
20
|
+
`INSTAR_AUTH_TOKEN` (set by `SessionManager` per spawned session and by
|
|
21
|
+
`JobScheduler` per scheduled job) is checked first. The disk fallback now
|
|
22
|
+
guards `cfg.authToken` against non-string values, so the literal
|
|
23
|
+
`{ "secret": true }` placeholder produced by `SecretMigrator` after
|
|
24
|
+
multi-machine pairing can never leak through as a Bearer token. (Previously,
|
|
25
|
+
scripts sent `[object Object]` or `{'secret': True}` as the Authorization
|
|
26
|
+
header, the server 403'd, and the script's downstream Python parser
|
|
27
|
+
silently exited on the error JSON — no output at all.)
|
|
28
|
+
|
|
29
|
+
- **Port-parse tolerates whitespace.** The `grep -o '"port":[0-9]*'` pattern
|
|
30
|
+
used in every hook required no whitespace between the colon and the
|
|
31
|
+
number, but our prettified `config.json` writes `"port": 4042` (with
|
|
32
|
+
space). The pattern silently produced empty `$PORT`, the hook exited
|
|
33
|
+
early, and not a single HTTP request reached the server. Replaced with
|
|
34
|
+
`grep -oE '"port"[[:space:]]*:[[:space:]]*[0-9]+'` + digit-only extraction.
|
|
35
|
+
|
|
36
|
+
The structural cure includes a unit-tier lint that fails any future re-
|
|
37
|
+
introduction of the broken pattern (positive + negative-verified) and a new
|
|
38
|
+
migration pass (`migrateSecretExternalizationSurvivability`) that upgrades
|
|
39
|
+
deployed auxiliary scripts (`imessage-reply.sh`, `serendipity-capture.sh`,
|
|
40
|
+
`slack-channel-context.sh`) without touching custom forks. The canonical
|
|
41
|
+
always-overwrite hooks (`session-start.sh`, `compaction-recovery.sh`,
|
|
42
|
+
`telegram-topic-context.sh`) heal on next auto-update by the existing
|
|
43
|
+
`migrateHooks` path.
|
|
44
|
+
|
|
45
|
+
## What to Tell Your User
|
|
46
|
+
|
|
47
|
+
If your agent recently went silent after compaction — emitting only the
|
|
48
|
+
wall-clock-time block and then nothing else, despite a healthy server — this
|
|
49
|
+
is the fix. Topic-history injection is back. No configuration needed.
|
|
50
|
+
|
|
51
|
+
After auto-update, send yourself a Telegram message in any forum topic.
|
|
52
|
+
The agent's response should reference what you actually said — not greet
|
|
53
|
+
you like it's the first message. If it doesn't, something else is wrong;
|
|
54
|
+
please flag.
|
|
55
|
+
|
|
56
|
+
## Summary of New Capabilities
|
|
57
|
+
|
|
58
|
+
| Capability | How to Use |
|
|
59
|
+
|-----------|-----------|
|
|
60
|
+
| Auth-token resolution survives secret-externalization | Automatic. Every shipped script + hook now reads `INSTAR_AUTH_TOKEN` env first, then falls back to `config.json` with a string-type guard. |
|
|
61
|
+
| Port-parse tolerates JSON whitespace | Automatic. Hooks no longer exit-early when `config.json` is prettified. |
|
|
62
|
+
| Lint blocks future regressions | `tests/unit/secret-externalization-hook-resolver-lint.test.ts` greps every shell + Node read for the broken pattern and fails on any reintroduction. |
|
|
63
|
+
| Auxiliary-script migration for deployed agents | Existing agents with the old `imessage-reply.sh` / `serendipity-capture.sh` / `slack-channel-context.sh` get the fix on next auto-update via `migrateSecretExternalizationSurvivability`. Custom forks (no shipped marker) are untouched. |
|
|
64
|
+
|
|
65
|
+
## Evidence
|
|
66
|
+
|
|
67
|
+
- 13 new tests across 3 tiers (unit + integration-grade + e2e). All green on a
|
|
68
|
+
full `vitest` run.
|
|
69
|
+
- 35 PostUpdateMigrator-* unit test files (232 tests) verified clean (no
|
|
70
|
+
regression from migrator extensions).
|
|
71
|
+
- Manual smoke test against a real externalized agent: the fixed
|
|
72
|
+
`getTelegramTopicContextHook()` content correctly fetches topic history
|
|
73
|
+
with INSTAR_AUTH_TOKEN env alone (config.json holds the placeholder), and
|
|
74
|
+
correctly degrades to the unauth branch without leaking the placeholder
|
|
75
|
+
when env is missing.
|
|
76
|
+
- Side-effects review:
|
|
77
|
+
`upgrades/side-effects/secret-externalization-hook-resolver-audit.md`.
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# vNEXT — plain English overview
|
|
2
|
+
|
|
3
|
+
## What this change is
|
|
4
|
+
|
|
5
|
+
Your agent has a bunch of helper scripts that need to talk to its own local
|
|
6
|
+
server. They authenticate using an "auth token" — basically a password the
|
|
7
|
+
server expects in every request.
|
|
8
|
+
|
|
9
|
+
Recently we added a feature that **moves that password out of the plain
|
|
10
|
+
config file into an encrypted store**, so the password no longer sits in
|
|
11
|
+
plain text on disk. That's a good security improvement.
|
|
12
|
+
|
|
13
|
+
But when we did that, we forgot to update *every* place the helpers read
|
|
14
|
+
the password from. So a bunch of helpers kept reading the plain config
|
|
15
|
+
file, found the placeholder marker (a tiny note that says "the real
|
|
16
|
+
password lives in the encrypted store now"), and used **that marker** as
|
|
17
|
+
the password. The server rejected every request. The helpers didn't
|
|
18
|
+
notice the rejection — they just quietly emitted nothing.
|
|
19
|
+
|
|
20
|
+
The user-visible result: your agent comes back from a memory compaction
|
|
21
|
+
and has no idea what conversation you were in. So it greets you like a
|
|
22
|
+
stranger.
|
|
23
|
+
|
|
24
|
+
## What already exists
|
|
25
|
+
|
|
26
|
+
- The `INSTAR_AUTH_TOKEN` environment variable. Every time the agent
|
|
27
|
+
spawns a new session, it sets this env var with the real password.
|
|
28
|
+
- The encrypted secret store at `.instar/secrets/config.secrets.enc`,
|
|
29
|
+
managed by `SecretMigrator` / `SecretStore`.
|
|
30
|
+
- A migration system (`PostUpdateMigrator`) that rewrites helper scripts
|
|
31
|
+
on every auto-update. Some helpers were already on this track; others
|
|
32
|
+
weren't.
|
|
33
|
+
|
|
34
|
+
## What's new
|
|
35
|
+
|
|
36
|
+
- **Every helper now reads `INSTAR_AUTH_TOKEN` env first.** The env var
|
|
37
|
+
is always present in a session, so it's the canonical path.
|
|
38
|
+
- **The disk-fallback now has a "is this actually a password?" check.**
|
|
39
|
+
If the config file says `{ "secret": true }` (the placeholder), the
|
|
40
|
+
guard rejects it and falls through to no-auth instead of sending the
|
|
41
|
+
placeholder as a password.
|
|
42
|
+
- **A second, unrelated bug** also got fixed in the same change: the
|
|
43
|
+
helpers parsed the server port from the config file with a regex that
|
|
44
|
+
refused to allow whitespace, so `"port": 4042` (the format we
|
|
45
|
+
actually ship) was unparseable. The helpers exited silently with no
|
|
46
|
+
port, hitting nothing. Replaced with a whitespace-tolerant pattern.
|
|
47
|
+
- **A new automated check** scans every helper for this broken pattern
|
|
48
|
+
and refuses to commit any future change that reintroduces it.
|
|
49
|
+
- **An update routine** that fixes the helpers on existing deployed
|
|
50
|
+
agents (without overwriting any custom scripts the user wrote
|
|
51
|
+
themselves).
|
|
52
|
+
|
|
53
|
+
## What you need to decide
|
|
54
|
+
|
|
55
|
+
Nothing. This is a bug fix, no configuration involved. Auto-update will
|
|
56
|
+
heal every deployed agent on next version bump.
|
|
57
|
+
|
|
58
|
+
## How to verify it worked after deploy
|
|
59
|
+
|
|
60
|
+
After your agent updates past the version that includes this fix, send
|
|
61
|
+
yourself a Telegram message in any forum topic. The agent's response
|
|
62
|
+
should reference what you actually said — not greet you like it's the
|
|
63
|
+
first message. If it doesn't, something else is wrong; please flag.
|
|
64
|
+
|
|
65
|
+
## Why this matters more than it might look
|
|
66
|
+
|
|
67
|
+
This is a "silent failure" class — the helpers were broken but emitted
|
|
68
|
+
no error, so the bug only surfaced through the symptom (incoherent
|
|
69
|
+
agent replies after compaction). That makes the failure invisible until
|
|
70
|
+
a user notices the symptom. The structural lint added in this change
|
|
71
|
+
makes the class itself impossible to reintroduce silently.
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# Side-effects review — HTTP presence transport (PeerPresencePuller)
|
|
2
|
+
|
|
3
|
+
## What was happening (real-hardware, 2026-05-29)
|
|
4
|
+
|
|
5
|
+
The Multi-Machine Session Pool's live-transfer proof (laptop ↔ Mac mini) got all
|
|
6
|
+
the way to the edge and surfaced one architectural gap: the mesh **forms** fine
|
|
7
|
+
over HTTP (non-interactive code-auth pairing, URLs exchanged, peers reachable at
|
|
8
|
+
their tunnels, split-brain clear), but the router would not transfer a session
|
|
9
|
+
to the mini because it saw the mini as `online: false`.
|
|
10
|
+
|
|
11
|
+
Root cause: the pool registry's notion of which peers are ONLINE was fed ONLY by
|
|
12
|
+
the **git-synced** `MachineHeartbeat` — each machine writes its heartbeat into
|
|
13
|
+
the shared agent repo and peers pull it. The mini is paired into the mesh over
|
|
14
|
+
HTTP but is `gh`-authed to a *different* account, so it has no push access to the
|
|
15
|
+
shared repo. Its heartbeat never reaches the router → the router marks it offline
|
|
16
|
+
→ the placement engine refuses to transfer to an offline machine — even though
|
|
17
|
+
the two machines reach each other perfectly over their tunnels.
|
|
18
|
+
|
|
19
|
+
## The fix
|
|
20
|
+
|
|
21
|
+
A pull-based HTTP presence channel that does not touch git:
|
|
22
|
+
|
|
23
|
+
- **`src/core/PeerPresencePuller.ts`** (new) — on a cadence, ask each reachable
|
|
24
|
+
peer for its self-capacity over the signed §L0 `session-status` MeshRpc command
|
|
25
|
+
and feed the answer into the local `MachinePoolRegistry.recordHeartbeat`. A peer
|
|
26
|
+
that answers is by definition reachable + alive, so it goes `online` purely over
|
|
27
|
+
HTTP. Fully injected (peers, fetch, record, clock) and `pullOnce()` NEVER throws
|
|
28
|
+
— an unreachable peer is simply not recorded this pass and ages out of `online`
|
|
29
|
+
via the registry's existing failover threshold.
|
|
30
|
+
|
|
31
|
+
- **`src/commands/server.ts`** — in the mesh-rpc block (right after the
|
|
32
|
+
SessionRouter is wired, where `meshClient` / `peerUrl` / `machinePoolRegistry`
|
|
33
|
+
are in scope), construct one `PeerPresencePuller` and start it: an immediate
|
|
34
|
+
`pullOnce()` plus a 30 s `unref`-ed interval. `fetchPeerCapacity` issues
|
|
35
|
+
`meshClient.send(peer, { type: 'session-status' }, 0)`; `recordHeartbeat`
|
|
36
|
+
delegates to the real pool registry.
|
|
37
|
+
|
|
38
|
+
`session-status` is a read-class command (RBAC: any *registered* peer — proven by
|
|
39
|
+
`verifyEnvelope`), so the call authenticates off the mutual identity established
|
|
40
|
+
at pairing. No router role, no epoch fence, no new route, no new config.
|
|
41
|
+
|
|
42
|
+
## Blast radius
|
|
43
|
+
|
|
44
|
+
- **Additive + symmetric.** Every mesh machine runs one puller; each maintains
|
|
45
|
+
its own HTTP-sourced view of peer liveness. It runs in PARALLEL with the
|
|
46
|
+
existing git-synced `refreshPool` path — a credentialed peer that both
|
|
47
|
+
git-syncs AND answers HTTP just refreshes twice. `recordHeartbeat` is the single
|
|
48
|
+
arbiter and the router's-clock liveness rule is unchanged, so a peer can never
|
|
49
|
+
be marked *fresher* than its real `routerReceivedAt`.
|
|
50
|
+
- **Inert without a mesh.** A single-machine agent has no peers → `listPeers()` is
|
|
51
|
+
empty → `pullOnce()` is a no-op. Only constructed inside the existing
|
|
52
|
+
mesh-enabled block.
|
|
53
|
+
- **No new config / route / schema / hook / skill → no migration.** Server +
|
|
54
|
+
one new core module; existing agents pick it up on the next release (same shape
|
|
55
|
+
as the native-rebuild / wake-socket fleet fix).
|
|
56
|
+
- **Does NOT replace the git substrate** for ownership/placement *records* (§L0)
|
|
57
|
+
— only the high-frequency presence signal moves to HTTP.
|
|
58
|
+
|
|
59
|
+
## Tests
|
|
60
|
+
|
|
61
|
+
- `tests/unit/peer-presence-puller.test.ts` — loop semantics with injected fakes:
|
|
62
|
+
a peer that answers is recorded (id + loadAvg + selfReportedLastSeen); self is
|
|
63
|
+
never polled; a peer with no URL is skipped; a fetch that REJECTS does not throw
|
|
64
|
+
and does not record (peer ages out); a null result is not recorded; the clock
|
|
65
|
+
fallback fills `selfReportedLastSeen`; `listPeers` is re-read each pass.
|
|
66
|
+
- `tests/unit/peer-presence-wiring.test.ts` — pins the server-boot wiring:
|
|
67
|
+
imports + constructs a real puller, feeds the signed `session-status` call into
|
|
68
|
+
`machinePoolRegistry?.recordHeartbeat`, and actually STARTS it (immediate
|
|
69
|
+
`pullOnce()` + recurring `unref`-ed timer) — guards against the
|
|
70
|
+
"constructed-but-inert" failure the Testing Integrity Standard calls out.
|
|
71
|
+
- `tests/integration/peer-presence-roundtrip.test.ts` — the full chain over a real
|
|
72
|
+
loopback `/mesh/rpc` with real Ed25519 keys: MINI starts OFFLINE +
|
|
73
|
+
placement-ineligible on the router (no git heartbeat); after ONE presence pass
|
|
74
|
+
over the signed channel it is ONLINE + placement-eligible (loadAvg carried over
|
|
75
|
+
HTTP) — and an unreachable peer is never marked online.
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# Side-effects review — secret-externalization hook resolver audit
|
|
2
|
+
|
|
3
|
+
## What changed
|
|
4
|
+
|
|
5
|
+
Two related fleet-wide classes are fixed in one PR.
|
|
6
|
+
|
|
7
|
+
1. **Auth-token reads survive secret-externalization.** Every shipped shell hook, shell script, Node helper, and migrator-emitted template string that resolves the instar server's `authToken` now:
|
|
8
|
+
- Prefers `INSTAR_AUTH_TOKEN` env (set by `SessionManager` per spawned session and by `JobScheduler` per scheduled job — always available inside the Claude Code session).
|
|
9
|
+
- Falls back to reading `config.json` with a string-type guard. When `SecretMigrator` has externalized the token, on-disk `authToken` is the literal placeholder `{ "secret": true }`; the guard rejects it and yields empty so the placeholder cannot leak as a Bearer.
|
|
10
|
+
2. **Port-parse regex tolerates whitespace.** `grep -o '"port":[0-9]*'` is replaced by `grep -oE '"port"[[:space:]]*:[[:space:]]*[0-9]+'` + digit-only extraction. The previous form silently produced empty PORT whenever `config.json` had `"port": 4042` (with space) — and our prettified configs all do — making every history-injection hook exit early on a healthy install. Both bugs together produced the 2026-05-29 silent-history failure.
|
|
11
|
+
|
|
12
|
+
## Why
|
|
13
|
+
|
|
14
|
+
The 2026-05-29 incident: Justin's `telegram-topic-context.sh` stopped injecting topic history after the agent compacted. The hook silently 403'd because `authToken` had been moved to the encrypted secret store and the hook was still reading the literal `{ secret: true }` placeholder out of `config.json` as a Bearer token. The agent came back with no topic context, sent an incoherent reply, and Justin had to manually trace the root cause back. This is the SECOND time a script in this class has bitten a deployed agent (the 2026-05-28 `telegram-reply.sh` 403 was the first), so the fix is structural: env-first everywhere + string-type guard + lint that catches the broken pattern + migrator that upgrades stale-on-disk copies.
|
|
15
|
+
|
|
16
|
+
## Risk surface
|
|
17
|
+
|
|
18
|
+
- **Hook scripts (always-overwritten):** `session-start.sh`, `compaction-recovery.sh`, `telegram-topic-context.sh`. Existing agents get the fix on first auto-update. The canonical content lives inside `PostUpdateMigrator.getHookContent()`; the parallel `src/templates/hooks/*.sh` files are kept in sync (the manifest tracks their SHA256). Risk: low — these are already overwritten on every migration tick.
|
|
19
|
+
- **Reply scripts (SHA-based migration):** `telegram-reply.sh` upgrades via `migrateReplyScriptToPortConfig` (pre-PR SHA `371d7e8f…` is already in the prior-shipped allowlist; agents get the fix on first auto-update). `slack-reply.sh` and `whatsapp-reply.sh` upgrade via `migrateReplyScriptTo408`, extended to also fire when `INSTAR_AUTH_TOKEN` is missing — previously the function skipped any script with HTTP 408 handling, which would have stranded a 408-aware-but-auth-broken copy forever. Risk: low — the marker-string check still protects custom forks.
|
|
20
|
+
- **Auxiliary scripts (new migration):** `imessage-reply.sh`, `serendipity-capture.sh`, `slack-channel-context.sh` get a new `migrateSecretExternalizationSurvivability` pass that upgrades shipped copies and skips custom forks (shipped-marker check). Risk: low — guarded by both the marker check and the negative `INSTAR_AUTH_TOKEN` check.
|
|
21
|
+
- **CLI/in-process callers:** `gate.ts` and `nuke.ts` did raw `JSON.parse(fs.readFileSync('config.json'))` then `cfg.authToken` directly. Patched to env-first + string-type-guard. The other CLI commands go through `loadConfig()` (which calls `mergeConfigWithSecrets`) and are already safe. Risk: low — the change is the same env-first/string-guard pattern.
|
|
22
|
+
- **Daemons:** `listener-daemon.ts` (HMAC-derives a signing key from `authToken`), `mcp-stdio-entry.ts` (uses `authToken` as the inbound Bearer). Both now env-first + string-type-guard. Risk: low — the env-first path is preferred when the daemon is launched by the spawn pipeline (which exports the var); the disk fallback still works for plaintext-config installs.
|
|
23
|
+
- **CI / lint:** new unit test `secret-externalization-hook-resolver-lint.test.ts` greps every shell + Node read of `authToken`. The lint MUST go green on the fixed tree (verified) and MUST fail on any re-introduction of the broken pattern (verified via destructive negative test). Risk: low — the lint is allowlisted for in-process `loadConfig` callers; future authors won't trip on the safe path.
|
|
24
|
+
|
|
25
|
+
## Bug surfaces eliminated
|
|
26
|
+
|
|
27
|
+
- Topic-history injection silently 403s after secret externalization. (Direct cause of the 2026-05-29 incident.)
|
|
28
|
+
- `telegram-reply.sh` 403s when invoked from a session whose env is missing — partially mitigated in the 2026-05-28 fix but the audit confirmed the disk-fallback path was still broken; this PR closes both halves.
|
|
29
|
+
- `session-start.sh` could not fetch working-memory or shared-state after externalization (same root cause as the topic-history bug).
|
|
30
|
+
- `compaction-recovery.sh` lost its post-compaction memory query after externalization.
|
|
31
|
+
- A standby-machine daemon whose key derivation went through `authToken` would silently derive a key from `{ secret: true }` and lose peer connectivity. (Speculative — no incident observed, but the path was broken.)
|
|
32
|
+
|
|
33
|
+
## Migration footprint
|
|
34
|
+
|
|
35
|
+
- `migrateHooks()` already always-overwrites the three canonical hooks → existing agents heal automatically on next auto-update.
|
|
36
|
+
- `migrateScripts()` already SHA-migrates `telegram-reply.sh` and now (with the extended INSTAR_AUTH_TOKEN check) auto-migrates `slack-reply.sh` + `whatsapp-reply.sh` when the previously-shipped marker is present.
|
|
37
|
+
- `migrateSecretExternalizationSurvivability()` (NEW) auto-migrates `imessage-reply.sh`, `serendipity-capture.sh`, `slack-channel-context.sh`. Idempotent on the post-fix shape.
|
|
38
|
+
|
|
39
|
+
## Testing
|
|
40
|
+
|
|
41
|
+
- Unit (lint): `tests/unit/secret-externalization-hook-resolver-lint.test.ts` — 3 tests. Greps `src/templates/`, `src/`, `scripts/` for the broken pattern; verified negative (broken pattern caught) and positive (clean pattern passes).
|
|
42
|
+
- Unit (migrator): `tests/unit/secret-externalization-survivability-migrator.test.ts` — 5 tests. Seeds broken state, runs `migrate()`, asserts upgrade. Custom-fork test verifies the marker check protects user-modified scripts.
|
|
43
|
+
- Integration-grade (in `tests/unit/`): `tests/unit/secret-externalization-hook-injection.test.ts` — 2 tests. Spins an in-process stub server (bound to all interfaces; ASYNC `spawn` is required because `execFileSync` blocks Node's event loop and freezes the stub). Seeds `config.json` with the placeholder, runs the canonical migrator-emitted hook, asserts injection works with env-first auth and never leaks the placeholder as a Bearer.
|
|
44
|
+
- E2E: `tests/e2e/secret-externalization-hook-lifecycle.test.ts` — 3 tests. Simulates pre-fix agent → `migrate()` → asserts every shipped script carries the env-first canary; verifies idempotency on a second pass; verifies the canonical migrator-emitted hooks always carry the canary.
|
|
45
|
+
|
|
46
|
+
## Test count
|
|
47
|
+
|
|
48
|
+
13 new tests across 3 tiers (per Testing Integrity Standard). 29 existing related tests (`PostUpdateMigrator-time-injection.test.ts`, `compaction-telegram-context.test.ts`) remain green.
|