sprag-cli 3.69.0 → 3.71.0
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/NOTICE +5 -2
- package/README.ko.md +16 -0
- package/README.md +16 -0
- package/bin/cli.js +53 -4
- package/docs/CODEX.ko.md +3 -2
- package/docs/CODEX.md +5 -4
- package/docs/TEAM.ko.md +174 -0
- package/docs/TEAM.md +175 -0
- package/package.json +5 -1
- package/presets/agents.json +118 -0
- package/presets/doc2md/convert.py +1 -1
- package/presets/ratchet-rules.json +12 -2
- package/src/agents.js +3 -1
- package/src/cli-entry.js +40 -0
- package/src/codex/delegation.js +8 -5
- package/src/codex/hooks.js +25 -12
- package/src/codex/panel-setup.js +2 -1
- package/src/codex/panel.js +2 -1
- package/src/codex/route-scan.js +6 -4
- package/src/commands/brief.js +2 -0
- package/src/commands/codex-delegate.js +3 -3
- package/src/commands/codex.js +6 -5
- package/src/commands/harness.js +3 -4
- package/src/commands/install.js +19 -18
- package/src/commands/presets.js +158 -0
- package/src/commands/profile-map.js +2 -2
- package/src/commands/route-scan.js +25 -10
- package/src/commands/team.js +334 -0
- package/src/commands/update-check.js +2 -2
- package/src/cost.js +6 -2
- package/src/cowork-sync.js +5 -4
- package/src/debug.js +3 -2
- package/src/delegation-agents.js +49 -49
- package/src/delegation-guard.js +3 -2
- package/src/doc2md.cjs +4 -3
- package/src/env.cjs +7 -0
- package/src/env.js +19 -0
- package/src/first-run-note.js +3 -2
- package/src/harness-templates.js +10 -0
- package/src/harness.js +4 -14
- package/src/hook.cjs +4 -3
- package/src/model-rules.js +58 -29
- package/src/preset-sign.js +106 -0
- package/src/preset-sync.js +133 -0
- package/src/presets-source.js +209 -0
- package/src/prompt.js +2 -1
- package/src/route-caps.js +3 -3
- package/src/route-inject.js +24 -15
- package/src/route-scan.js +39 -15
- package/src/seed-rules.js +14 -16
- package/src/shared-model-rules.js +1 -1
- package/src/star-note.js +3 -2
- package/src/statusline-guard.js +144 -0
- package/src/stdin-payload.js +33 -4
- package/src/team-prompts.js +353 -0
- package/src/team-ratchet.js +276 -0
- package/src/team-sync.js +625 -0
- package/src/update-check.js +3 -2
package/src/env.js
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reads a SPRAG_ environment variable, falling back to its legacy CTS_ name.
|
|
3
|
+
*
|
|
4
|
+
* The package used to be called claude-token-saver and its variables carried a
|
|
5
|
+
* CTS_ prefix. Users still have those set in shells, CI and Claude Code managed
|
|
6
|
+
* settings, so every read takes the SPRAG_ name first and the CTS_ name second.
|
|
7
|
+
* Pass the suffix: envVar('NO_TEAM_SYNC') answers env.SPRAG_NO_TEAM_SYNC ??
|
|
8
|
+
* env.CTS_NO_TEAM_SYNC.
|
|
9
|
+
*
|
|
10
|
+
* src/env.cjs is the CommonJS twin for the .cjs files; test/env.test.js pins
|
|
11
|
+
* the two together.
|
|
12
|
+
*
|
|
13
|
+
* @param {string} name the variable name without its prefix
|
|
14
|
+
* @param {Record<string, string | undefined>} [env]
|
|
15
|
+
* @returns {string | undefined}
|
|
16
|
+
*/
|
|
17
|
+
export function envVar(name, env = process.env) {
|
|
18
|
+
return env['SPRAG_' + name] ?? env['CTS_' + name];
|
|
19
|
+
}
|
package/src/first-run-note.js
CHANGED
|
@@ -5,10 +5,11 @@
|
|
|
5
5
|
* ONCE per note key and then never again: the shown-at timestamp is persisted
|
|
6
6
|
* next to the other state files and checked before anything is printed.
|
|
7
7
|
*
|
|
8
|
-
* Opt out entirely with
|
|
8
|
+
* Opt out entirely with SPRAG_NO_NOTE=1 (also honoured by anything that pipes
|
|
9
9
|
* our output somewhere it does not belong).
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
|
+
import { envVar } from './env.js';
|
|
12
13
|
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs';
|
|
13
14
|
import { join } from 'node:path';
|
|
14
15
|
import { userDataDir } from './paths.js';
|
|
@@ -53,7 +54,7 @@ export function markNoteShown(key, now = Date.now()) {
|
|
|
53
54
|
/** True only the first time this key is asked about. Best-effort — a
|
|
54
55
|
* read-only state dir just means the note repeats, never that we crash. */
|
|
55
56
|
export function shouldShowOnce(key, { now = Date.now() } = {}) {
|
|
56
|
-
if (
|
|
57
|
+
if (envVar('NO_NOTE') === '1') return false;
|
|
57
58
|
if (load()[key]) return false;
|
|
58
59
|
try {
|
|
59
60
|
markShown(key, now);
|
package/src/harness-templates.js
CHANGED
|
@@ -52,6 +52,16 @@ export function presetRatchetImportLine() {
|
|
|
52
52
|
}
|
|
53
53
|
export const PRESET_RATCHET_IMPORT_RE = /^@~\/\.claude\/ratchet-preset\.md\s*$/m;
|
|
54
54
|
|
|
55
|
+
/**
|
|
56
|
+
* The team ratchet: rules the user's team approved, cached by `sprag team sync`
|
|
57
|
+
* and rendered into a tool-owned file. Unlike the others its import is not in
|
|
58
|
+
* the rendered block; it is added only for members, when the file exists.
|
|
59
|
+
*/
|
|
60
|
+
export function teamRatchetImportLine() {
|
|
61
|
+
return '@~/.claude/ratchet-team.md';
|
|
62
|
+
}
|
|
63
|
+
export const TEAM_RATCHET_IMPORT_RE = /^@~\/\.claude\/ratchet-team\.md\s*$/m;
|
|
64
|
+
|
|
55
65
|
/**
|
|
56
66
|
* Render ratchet-preset.md. `rules` is [{ id, text }] in display order. The
|
|
57
67
|
* file is loaded into every session, so the header stays short.
|
package/src/harness.js
CHANGED
|
@@ -11,7 +11,6 @@ import { readFileSync, writeFileSync, existsSync, mkdirSync, statSync } from 'no
|
|
|
11
11
|
import { join, resolve, dirname } from 'node:path';
|
|
12
12
|
import { homedir } from 'node:os';
|
|
13
13
|
import { createRequire } from 'node:module';
|
|
14
|
-
import { fileURLToPath } from 'node:url';
|
|
15
14
|
import {
|
|
16
15
|
HARNESS_SECTIONS,
|
|
17
16
|
HARNESS_BLOCK_BEGIN,
|
|
@@ -28,6 +27,7 @@ import { ruleHealthWarningForStatusline, modelRatchetPathFor, renderModelRatchet
|
|
|
28
27
|
import { compactWindowWarningForStatusline } from './compact-window.js';
|
|
29
28
|
import { userLanguage } from './config.js';
|
|
30
29
|
import { codexUserDir } from './agent.js';
|
|
30
|
+
import { ratchetPresetEntries } from './presets-source.js';
|
|
31
31
|
|
|
32
32
|
const require = createRequire(import.meta.url);
|
|
33
33
|
function readHarnessState() {
|
|
@@ -441,19 +441,9 @@ export async function harnessPull({ root = findProjectRoot(), scope = 'global',
|
|
|
441
441
|
* turns a rule the user already answered into a new one.
|
|
442
442
|
*/
|
|
443
443
|
export function presetRuleEntries() {
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
// file into an empty rule set without ever saying so.
|
|
448
|
-
const path = join(dirname(fileURLToPath(import.meta.url)), '..', 'presets', 'ratchet-rules.json');
|
|
449
|
-
const data = JSON.parse(readFileSync(path, 'utf8'));
|
|
450
|
-
return (Array.isArray(data.rules) ? data.rules : [])
|
|
451
|
-
.filter((r) => r && typeof r.ko === 'string' && r.ko.trim())
|
|
452
|
-
.map((r) => ({ ko: r.ko.trim(), en: (r.en || r.ko).trim(),
|
|
453
|
-
...(Array.isArray(r.agents) ? { agents: r.agents.filter((a) => typeof a === 'string') } : {}) }));
|
|
454
|
-
} catch {
|
|
455
|
-
return [];
|
|
456
|
-
}
|
|
444
|
+
// The published bundle when it verifies, else presets/ratchet-rules.json
|
|
445
|
+
// (presets-source.js). Each entry carries its stable id.
|
|
446
|
+
return ratchetPresetEntries();
|
|
457
447
|
}
|
|
458
448
|
|
|
459
449
|
/** Preset rule text in the user's configured language. */
|
package/src/hook.cjs
CHANGED
|
@@ -13,11 +13,12 @@
|
|
|
13
13
|
'use strict';
|
|
14
14
|
|
|
15
15
|
// CJS twin of src/debug.js \u2014 hook failures must stay silent for the session
|
|
16
|
-
// but be visible under
|
|
16
|
+
// but be visible under SPRAG_DEBUG=1, otherwise a broken path is
|
|
17
17
|
// indistinguishable from "nothing to do".
|
|
18
|
-
|
|
18
|
+
// Standalone hook (no requires), so the SPRAG_ -> CTS_ fallback is inline.
|
|
19
|
+
var DEBUG_ON = !!(process.env.SPRAG_DEBUG ?? process.env.CTS_DEBUG);
|
|
19
20
|
function dbg(scope, err) {
|
|
20
|
-
if (!
|
|
21
|
+
if (!DEBUG_ON) return;
|
|
21
22
|
process.stderr.write('[cts:' + scope + '] ' + ((err && err.stack) || String(err)) + '\n');
|
|
22
23
|
}
|
|
23
24
|
|
package/src/model-rules.js
CHANGED
|
@@ -72,7 +72,8 @@ export function wilsonLowerBound(errs, n) {
|
|
|
72
72
|
// route-scan's pre-calibration defaults. Kept as local constants rather than
|
|
73
73
|
// imported: route-scan imports this module, and a static back-import would
|
|
74
74
|
// close the cycle.
|
|
75
|
-
const DEFAULT_BUDGET = { T2: { calls: 8, out: 1500 }, T1: { calls: null, out: 8000 }
|
|
75
|
+
const DEFAULT_BUDGET = { T2: { calls: 8, out: 1500 }, T1: { calls: null, out: 8000 },
|
|
76
|
+
T1H: { calls: null, out: 30000 } };
|
|
76
77
|
|
|
77
78
|
/** The budget a rule is held to: its calibrated one, or the default of its tier. */
|
|
78
79
|
export function ruleBudget(rule) {
|
|
@@ -132,20 +133,27 @@ function shortExample(text) {
|
|
|
132
133
|
return one.length <= EXAMPLE_MAX ? one : `${one.slice(0, EXAMPLE_MAX - 1)}…`;
|
|
133
134
|
}
|
|
134
135
|
|
|
135
|
-
// T1 is named through its delegation agent only when that file
|
|
136
|
-
// it the literal `model: sonnet`
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
136
|
+
// T1 (and T1H) is named through its delegation agent only when that file
|
|
137
|
+
// exists; without it the literal `model: sonnet` / `model: opus` stays so
|
|
138
|
+
// output on such machines is unchanged.
|
|
139
|
+
function t1Target(root, ko, tier = 'sonnet') {
|
|
140
|
+
if (resolveAgentName(tier, root) === null) return null;
|
|
141
|
+
return (ko ? agentPhrase : agentPhraseEn)(tier, { root });
|
|
140
142
|
}
|
|
141
143
|
|
|
142
144
|
export function modelRuleBaseText(g, lang = userLanguage(), { root } = {}) {
|
|
143
145
|
if (lang === 'ko') {
|
|
146
|
+
if (g.tier === 'T1H') {
|
|
147
|
+
return `"${g.label}" 고난도 구현 요청(예: "${shortExample(g.example)}")은 Fable·Mythos 세션에 한해 ${t1Target(root, true, 'opus') ?? 'model: opus'}에 위임`;
|
|
148
|
+
}
|
|
144
149
|
return g.tier === 'T2'
|
|
145
150
|
? `"${g.label}" 단순 요청(예: "${shortExample(g.example)}")은 ${agentPhrase(g.agent, { root })}에 위임`
|
|
146
151
|
: `"${g.label}" 중간 난도 요청(예: "${shortExample(g.example)}")은 ${t1Target(root, true) ?? 'model: sonnet'}에 위임`;
|
|
147
152
|
}
|
|
148
153
|
const example = g.exampleEn || g.example;
|
|
154
|
+
if (g.tier === 'T1H') {
|
|
155
|
+
return `Heavy "${g.labelEn}" implementation requests (e.g. "${shortExample(example)}") go to ${t1Target(root, false, 'opus') ?? 'a model: opus subagent'}, in Fable/Mythos sessions only`;
|
|
156
|
+
}
|
|
149
157
|
return g.tier === 'T2'
|
|
150
158
|
? `Simple "${g.labelEn}" requests (e.g. "${shortExample(example)}") go to ${agentPhraseEn(g.agent, { root })}`
|
|
151
159
|
: `Moderate "${g.labelEn}" requests (e.g. "${shortExample(example)}") go to ${t1Target(root, false) ?? 'a model: sonnet subagent'}`;
|
|
@@ -275,8 +283,12 @@ export function renderModelRatchet(rules, lang = userLanguage(), { root } = {})
|
|
|
275
283
|
'- **effort**: 모델 티어와 다른 축이다. 설치된 서브에이전트는 검증 형태에 따라',
|
|
276
284
|
' effort가 고정되어 있다. 명령 출력이나 파일 대조로 검증되는 위임(조회·명령',
|
|
277
285
|
' 실행·정형 변환을 맡는 haiku 에이전트)은 low, 확정된 설계를 테스트에 맞춰',
|
|
278
|
-
' 구현하는 위임(sonnet 워커)은 medium
|
|
286
|
+
' 구현하는 위임(sonnet 워커)은 medium, 범위가 큰 구현·디버깅 위임(opus 워커)은',
|
|
287
|
+
' high다. 메인 세션의 effort는 바꾸지 않고,',
|
|
279
288
|
' CLAUDE_CODE_EFFORT_LEVEL은 서브에이전트의 effort를 덮어쓰므로 설정하지 않는다.',
|
|
289
|
+
'- **고난도 구현(T1H)**: 설계는 메인이 확정하고, 범위가 크거나 sonnet 이 틀린',
|
|
290
|
+
' 적 있는 구현·디버깅은 opus 에 위임한다. Fable·Mythos 세션에서만 적용되며,',
|
|
291
|
+
' 설계 판단과 되돌릴 수 없는 작업은 메인 모델에 남긴다.',
|
|
280
292
|
'- **위임 배치**: 서브에이전트 비용의 지배 항은 생성이 아니라 컨텍스트',
|
|
281
293
|
' 적재다. 같은 파일들을 읽어야 하는 작업 여러 개는 에이전트 하나에 묶어',
|
|
282
294
|
' 보내고, 읽는 영역이 다른 작업만 나눈다.',
|
|
@@ -337,8 +349,13 @@ export function renderModelRatchet(rules, lang = userLanguage(), { root } = {})
|
|
|
337
349
|
' by how their work is verified: low where the check is command output or a',
|
|
338
350
|
' file comparison (the haiku agents for lookups, command runs and mechanical',
|
|
339
351
|
' conversion), medium where a fixed design is implemented against tests (the',
|
|
340
|
-
' sonnet worker)
|
|
352
|
+
' sonnet worker), high for large implementation or debugging (the opus',
|
|
353
|
+
' worker). Leave the main session\'s effort alone, and never set',
|
|
341
354
|
' CLAUDE_CODE_EFFORT_LEVEL: it overrides every subagent\'s own value.',
|
|
355
|
+
'- **Heavy implementation (T1H)**: the main model fixes the design; large-scope',
|
|
356
|
+
' implementation or debugging, or work sonnet has already got wrong, goes to',
|
|
357
|
+
' opus. Applies in Fable/Mythos sessions only; design judgement and',
|
|
358
|
+
' irreversible work stay on the main model.',
|
|
342
359
|
'- **Batching**: a subagent\'s dominant cost is context ingestion, not',
|
|
343
360
|
' generation. Bundle tasks that read the same files into one agent; split',
|
|
344
361
|
' only when the areas they read differ.',
|
|
@@ -384,13 +401,13 @@ export function renderModelRatchet(rules, lang = userLanguage(), { root } = {})
|
|
|
384
401
|
const saved = r.savedUsd ? `, saved ~${signedUsd(r.savedUsd)}` : '';
|
|
385
402
|
return `${base}, delegated ×${r.delegatedRuns} err ${pct(r.delegatedErrRate)}%${saved}`;
|
|
386
403
|
};
|
|
387
|
-
// Merged
|
|
404
|
+
// Merged rules carry one cap per tier, so they state them once rather than
|
|
388
405
|
// repeating the whole stop-condition per tier.
|
|
389
|
-
const
|
|
390
|
-
|
|
391
|
-
|
|
406
|
+
const TIER_MODEL = { T2: 'haiku', T1: 'sonnet', T1H: 'opus' };
|
|
407
|
+
const mergedBudget = (tiers) => (ko ? '상한 ' : 'cap ') + tiers
|
|
408
|
+
.map((r) => `${TIER_MODEL[r.tier]} ${budgetCapPhrase(r, ko ? 'ko' : 'en')}`).join(' / ');
|
|
392
409
|
|
|
393
|
-
// A category can carry
|
|
410
|
+
// A category can carry a T2 (haiku), a T1 (sonnet) and a T1H (opus) rule. Tier is
|
|
394
411
|
// only known after an episode finishes, so two separate bullets give the
|
|
395
412
|
// reading LLM no way to pick one at request time — merge such pairs into a
|
|
396
413
|
// single conditional rule with explicit request-time criteria (default to
|
|
@@ -405,38 +422,50 @@ export function renderModelRatchet(rules, lang = userLanguage(), { root } = {})
|
|
|
405
422
|
// text stored on the rule is whatever wording and language were current when
|
|
406
423
|
// it was promoted, which is how an English line sat in a Korean file. A rule
|
|
407
424
|
// missing what the template needs still falls back to its stored text.
|
|
408
|
-
const baseTextOf = (r) => ((r.tier === 'T2' || r.tier === 'T1') && r.label && (ko || r.labelEn)
|
|
425
|
+
const baseTextOf = (r) => ((r.tier === 'T2' || r.tier === 'T1' || r.tier === 'T1H') && r.label && (ko || r.labelEn)
|
|
409
426
|
? modelRuleBaseText(r, lang, { root }) : r.rule);
|
|
427
|
+
const DELEGATION_TIERS = ['T2', 'T1', 'T1H'];
|
|
410
428
|
for (const group of byCategory.values()) {
|
|
411
|
-
const
|
|
412
|
-
const t1 = group.find((r) => r.tier === 'T1');
|
|
429
|
+
const picked = DELEGATION_TIERS.map((t) => group.find((r) => r.tier === t)).filter(Boolean);
|
|
413
430
|
// A second rule of a tier the line already covers (two global rules of one
|
|
414
431
|
// category, promoted from different projects) says nothing the line does
|
|
415
432
|
// not, so it adds no line of its own. Its review flag and its numbers stay
|
|
416
433
|
// visible on the line that speaks for it.
|
|
417
|
-
const extras =
|
|
418
|
-
const unknown = group.filter((r) => r
|
|
434
|
+
const extras = picked.length ? group.filter((r) => !picked.includes(r) && DELEGATION_TIERS.includes(r.tier)) : [];
|
|
435
|
+
const unknown = group.filter((r) => !picked.includes(r) && !extras.includes(r));
|
|
419
436
|
const extraHealth = extras.map(healthOf).join('');
|
|
420
437
|
const extraStats = extras.map((r) => ` / +${r.tier} ${statsOf(r)}`).join('');
|
|
421
|
-
if (
|
|
438
|
+
if (picked.length >= 2) {
|
|
422
439
|
// Seeded rules carry a localized example; scan-produced ones only have the
|
|
423
440
|
// user's own prompt, so exampleEn is a preference, not a requirement.
|
|
424
441
|
const ex = (r) => shortExample(ko ? r.example : (r.exampleEn || r.example));
|
|
425
|
-
|
|
442
|
+
const target = (r) => {
|
|
443
|
+
if (r.tier === 'T2') return (ko ? agentPhrase : agentPhraseEn)(r.agent, { root });
|
|
444
|
+
const name = r.tier === 'T1H' ? 'opus' : 'sonnet';
|
|
445
|
+
return t1Target(root, ko, name) ?? `model: ${name}`;
|
|
446
|
+
};
|
|
447
|
+
// One line per category, stating only what differs between the tiers.
|
|
426
448
|
// The exceptions this used to spell out (design judgement, irreversible
|
|
427
449
|
// work) are identical in every rule and now sit in the header's shared
|
|
428
450
|
// clauses, so a rule the model re-reads on every request is short enough to
|
|
429
451
|
// match at a glance.
|
|
452
|
+
const when = (r, first) => {
|
|
453
|
+
if (first) return ko ? '기본' : 'by default';
|
|
454
|
+
if (r.tier === 'T1H') return ko ? '범위가 크면(Fable·Mythos 세션 한정)' : 'for large scope (Fable/Mythos sessions only)';
|
|
455
|
+
return ko ? '여러 단계·여러 파일이 얽히면' : 'when it spans multiple steps or file edits';
|
|
456
|
+
};
|
|
457
|
+
const parts = picked.map((r, i) => (ko
|
|
458
|
+
? `${when(r, i === 0)} ${target(r)}(예: "${ex(r)}")`
|
|
459
|
+
: `${i === 0 ? `${target(r)} ${when(r, true)}` : `${target(r)} ${when(r, false)}`} (e.g. "${ex(r)}")`));
|
|
460
|
+
const head = picked[0];
|
|
430
461
|
const rule = ko
|
|
431
|
-
? `"${
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
} else if (t2 || t1) {
|
|
439
|
-
const r = t2 || t1;
|
|
462
|
+
? `"${head.label}": ${parts.join(', ')}. ${mergedBudget(picked)}`
|
|
463
|
+
: `"${head.labelEn || head.label}": ${parts.join(', ')}. ${mergedBudget(picked)}`;
|
|
464
|
+
const health = picked.map(healthOf).join('');
|
|
465
|
+
const stats = picked.map((r) => `${r.tier} ${statsOf(r)}`).join(' / ');
|
|
466
|
+
lines.push(`- ${rule}${health}${extraHealth} <!-- ${stats}${extraStats} -->`);
|
|
467
|
+
} else if (picked.length === 1) {
|
|
468
|
+
const r = picked[0];
|
|
440
469
|
lines.push(`- ${composeRuleText(baseTextOf(r), r, lang)}${healthOf(r)}${extraHealth} <!-- ${statsOf(r)}${extraStats} -->`);
|
|
441
470
|
}
|
|
442
471
|
for (const r of unknown) lines.push(`- ${composeRuleText(r.rule, r, lang)}${healthOf(r)} <!-- ${statsOf(r)} -->`);
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* preset-sign — the signature on a centrally published preset bundle.
|
|
3
|
+
*
|
|
4
|
+
* The superuser edits presets in the /ops console and signs each published
|
|
5
|
+
* bundle in the browser with an ECDSA P-256 key (WebCrypto, SHA-256). The CLI
|
|
6
|
+
* applies a remote bundle only when that signature verifies against a public key
|
|
7
|
+
* listed here, so a compromised server or a man in the middle cannot push
|
|
8
|
+
* ratchet text or agent prompts into every session: the worst it can do is
|
|
9
|
+
* withhold updates, and then the presets bundled in the npm package are used.
|
|
10
|
+
*
|
|
11
|
+
* The signing input is canonical JSON of the six bundle fields that carry
|
|
12
|
+
* meaning (format, version, min_cli, ratchet_rules, model_rules, agents): keys
|
|
13
|
+
* sorted recursively, no whitespace. site/assets/preset-sign.js is the browser
|
|
14
|
+
* copy of canonicalJson(); test/preset-sign.test.js signs with that copy and
|
|
15
|
+
* verifies with this one, so the two cannot drift apart.
|
|
16
|
+
*
|
|
17
|
+
* Verification here is synchronous (node:crypto with IEEE P1363 signatures,
|
|
18
|
+
* the encoding WebCrypto produces), because the presets are read on the
|
|
19
|
+
* session-start path by functions that are synchronous today. It takes well
|
|
20
|
+
* under a millisecond.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { createHash, createPublicKey, verify as cryptoVerify } from 'node:crypto';
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Public keys allowed to sign preset bundles: [{ kid, jwk }].
|
|
27
|
+
*
|
|
28
|
+
* EMPTY until the superuser creates the signing key in the /ops console
|
|
29
|
+
* (sprag.io/ops/#presets → Signing key → Create). After that, paste the public
|
|
30
|
+
* JWK shown there as one entry, with `kid` set to the fingerprint the console
|
|
31
|
+
* shows next to it, e.g.
|
|
32
|
+
*
|
|
33
|
+
* { kid: '0123456789abcdef', jwk: { kty: 'EC', crv: 'P-256', x: '…', y: '…' } }
|
|
34
|
+
*
|
|
35
|
+
* While this list is empty every remote bundle is rejected and the bundled
|
|
36
|
+
* presets are used; `sprag presets status` says so. Rotating the key means
|
|
37
|
+
* adding the new entry, releasing, then publishing with the new key; remove the
|
|
38
|
+
* old entry one release later.
|
|
39
|
+
*/
|
|
40
|
+
export const PRESET_SIGNING_KEYS = [];
|
|
41
|
+
|
|
42
|
+
export const PRESET_BUNDLE_FORMAT = 1;
|
|
43
|
+
export const PRESET_MAX_BYTES = 200 * 1024;
|
|
44
|
+
|
|
45
|
+
/** JSON with object keys sorted at every depth and no whitespace. */
|
|
46
|
+
export function canonicalJson(value) {
|
|
47
|
+
if (value === null || typeof value !== 'object') {
|
|
48
|
+
if (typeof value === 'number' && !Number.isFinite(value)) return 'null';
|
|
49
|
+
return value === undefined ? 'null' : JSON.stringify(value);
|
|
50
|
+
}
|
|
51
|
+
if (Array.isArray(value)) {
|
|
52
|
+
return '[' + value.map((v) => (v === undefined || typeof v === 'function' ? 'null' : canonicalJson(v))).join(',') + ']';
|
|
53
|
+
}
|
|
54
|
+
const keys = Object.keys(value).filter((k) => value[k] !== undefined && typeof value[k] !== 'function').sort();
|
|
55
|
+
return '{' + keys.map((k) => JSON.stringify(k) + ':' + canonicalJson(value[k])).join(',') + '}';
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** The exact bytes that are signed. Fields outside the six are not covered. */
|
|
59
|
+
export function signingInput(bundle) {
|
|
60
|
+
const b = bundle || {};
|
|
61
|
+
return canonicalJson({
|
|
62
|
+
format: b.format ?? null,
|
|
63
|
+
version: b.version ?? null,
|
|
64
|
+
min_cli: b.min_cli ?? null,
|
|
65
|
+
ratchet_rules: b.ratchet_rules ?? null,
|
|
66
|
+
model_rules: b.model_rules ?? null,
|
|
67
|
+
agents: b.agents ?? null,
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** sha256 of the canonical public JWK (kty, crv, x, y only), first 16 hex. */
|
|
72
|
+
export function jwkFingerprint(jwk) {
|
|
73
|
+
const pub = { crv: jwk?.crv, kty: jwk?.kty, x: jwk?.x, y: jwk?.y };
|
|
74
|
+
return createHash('sha256').update(canonicalJson(pub)).digest('hex').slice(0, 16);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function b64urlDecode(s) {
|
|
78
|
+
if (typeof s !== 'string' || !/^[A-Za-z0-9_-]+={0,2}$/.test(s)) return null;
|
|
79
|
+
return Buffer.from(s.replace(/=+$/, '').replace(/-/g, '+').replace(/_/g, '/'), 'base64');
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Check a bundle's signature. Returns { ok: true, kid } or
|
|
84
|
+
* { ok: false, reason } where reason is one of
|
|
85
|
+
* 'unsigned' the bundle carries no signature (version 1 is seeded unsigned)
|
|
86
|
+
* 'no-keys' this CLI has no signing key yet (PRESET_SIGNING_KEYS is empty)
|
|
87
|
+
* 'unknown-kid' signed by a key this CLI does not list
|
|
88
|
+
* 'bad-sig' the signature does not match the content
|
|
89
|
+
*/
|
|
90
|
+
export function verifyBundleSignature(bundle, keys = PRESET_SIGNING_KEYS) {
|
|
91
|
+
const sig = bundle && bundle.sig;
|
|
92
|
+
if (!sig || typeof sig !== 'object' || !sig.value) return { ok: false, reason: 'unsigned' };
|
|
93
|
+
if (!Array.isArray(keys) || keys.length === 0) return { ok: false, reason: 'no-keys' };
|
|
94
|
+
if (sig.alg !== 'ES256') return { ok: false, reason: 'bad-sig' };
|
|
95
|
+
const entry = keys.find((k) => k && k.kid === sig.kid);
|
|
96
|
+
if (!entry) return { ok: false, reason: 'unknown-kid' };
|
|
97
|
+
const raw = b64urlDecode(sig.value);
|
|
98
|
+
if (!raw || raw.length !== 64) return { ok: false, reason: 'bad-sig' };
|
|
99
|
+
try {
|
|
100
|
+
const key = createPublicKey({ key: { kty: 'EC', crv: 'P-256', x: entry.jwk.x, y: entry.jwk.y }, format: 'jwk' });
|
|
101
|
+
const ok = cryptoVerify('sha256', Buffer.from(signingInput(bundle), 'utf8'), { key, dsaEncoding: 'ieee-p1363' }, raw);
|
|
102
|
+
return ok ? { ok: true, kid: entry.kid } : { ok: false, reason: 'bad-sig' };
|
|
103
|
+
} catch {
|
|
104
|
+
return { ok: false, reason: 'bad-sig' };
|
|
105
|
+
}
|
|
106
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* preset-sync — fetch the centrally published preset bundle.
|
|
3
|
+
*
|
|
4
|
+
* Read-only and anonymous: two RPC calls to the same Supabase project the team
|
|
5
|
+
* layer uses, with the public anon key and no member key, so the request is all
|
|
6
|
+
* that leaves the machine. `presets_version()` is polled first because it is a
|
|
7
|
+
* few bytes; `presets_published()` is fetched only when the version moved.
|
|
8
|
+
*
|
|
9
|
+
* Cadence: at most once every 24 hours, from a detached child spawned on the
|
|
10
|
+
* SessionStart path (the same pattern as the update check and the team sync),
|
|
11
|
+
* so a session never waits on the network. The attempt is stamped before the
|
|
12
|
+
* spawn, so an offline machine does not spawn a doomed child every session.
|
|
13
|
+
*
|
|
14
|
+
* A fetched bundle is stored only when this CLI would apply it (see
|
|
15
|
+
* presets-source.js evaluateBundle). A rejected one is recorded with its reason
|
|
16
|
+
* so `sprag presets status` can say why the bundled presets are still in use.
|
|
17
|
+
*
|
|
18
|
+
* Opt out with SPRAG_NO_PRESET_SYNC=1 or SPRAG_NO_UPDATE_CHECK=1.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import * as childProcess from 'node:child_process';
|
|
22
|
+
import { userDataDir } from './paths.js';
|
|
23
|
+
import { writeStateFile } from './state-file.js';
|
|
24
|
+
import { debug } from './debug.js';
|
|
25
|
+
import {
|
|
26
|
+
cliVersion, evaluateBundle, presetSyncDisabled, readRemoteCache, remoteCachePath, resetPresetSourceMemo,
|
|
27
|
+
} from './presets-source.js';
|
|
28
|
+
import { PRESET_SIGNING_KEYS } from './preset-sign.js';
|
|
29
|
+
import { CLI_ENTRY, detachedChildEnv, isDetachedChild } from './cli-entry.js';
|
|
30
|
+
|
|
31
|
+
export const PRESET_SYNC_INTERVAL_MS = 24 * 60 * 60 * 1000;
|
|
32
|
+
|
|
33
|
+
function writeCache(cache, dir) {
|
|
34
|
+
writeStateFile(remoteCachePath(dir), JSON.stringify(cache, null, 2) + '\n');
|
|
35
|
+
resetPresetSourceMemo();
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Due when the last attempt (success or not) is 24 hours old, or there was none. */
|
|
39
|
+
export function presetSyncDue(cache, now = Date.now()) {
|
|
40
|
+
const at = Number(cache && cache.checkedAt);
|
|
41
|
+
return !Number.isFinite(at) || at <= 0 || now - at >= PRESET_SYNC_INTERVAL_MS || now < at;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function nextPresetCheckAt(cache) {
|
|
45
|
+
const at = Number(cache && cache.checkedAt);
|
|
46
|
+
return Number.isFinite(at) && at > 0 ? at + PRESET_SYNC_INTERVAL_MS : null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Called on session start. Never awaits the network. Returns true when a child
|
|
51
|
+
* was spawned.
|
|
52
|
+
*/
|
|
53
|
+
export function spawnPresetSyncIfDue({
|
|
54
|
+
env = process.env, now = Date.now(), dir = userDataDir(),
|
|
55
|
+
spawn = childProcess.spawn, cliPath = CLI_ENTRY, execPath = process.execPath,
|
|
56
|
+
} = {}) {
|
|
57
|
+
if (isDetachedChild(env) || presetSyncDisabled(env)) return false;
|
|
58
|
+
const cache = readRemoteCache(dir);
|
|
59
|
+
if (!presetSyncDue(cache, now)) return false;
|
|
60
|
+
try {
|
|
61
|
+
writeCache({ ...cache, checkedAt: now }, dir);
|
|
62
|
+
} catch (e) {
|
|
63
|
+
debug('preset-sync:stamp', e);
|
|
64
|
+
return false;
|
|
65
|
+
}
|
|
66
|
+
try {
|
|
67
|
+
spawn(execPath, [cliPath, 'presets', 'sync', '--background'],
|
|
68
|
+
{ detached: true, stdio: 'ignore', windowsHide: true, env: detachedChildEnv(env) }).unref();
|
|
69
|
+
return true;
|
|
70
|
+
} catch (e) {
|
|
71
|
+
debug('preset-sync:spawn', e);
|
|
72
|
+
return false;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Fetch now. Returns { status, version?, reason? } where status is
|
|
78
|
+
* 'disabled' | 'none' (nothing published) | 'current' (cached version is the
|
|
79
|
+
* published one) | 'updated' | 'rejected' | 'error'
|
|
80
|
+
* `force` refetches the bundle even when the version did not move.
|
|
81
|
+
*/
|
|
82
|
+
export async function syncPresets({
|
|
83
|
+
env = process.env, now = Date.now(), dir = userDataDir(), fetch, force = false,
|
|
84
|
+
version = cliVersion(), keys = PRESET_SIGNING_KEYS,
|
|
85
|
+
} = {}) {
|
|
86
|
+
if (presetSyncDisabled(env)) return { status: 'disabled' };
|
|
87
|
+
const { rpc, teamApiUrl, teamAnonKey } = await import('./team-sync.js');
|
|
88
|
+
const call = (fn) => rpc(fn, {}, { fetch, url: teamApiUrl(env), key: teamAnonKey(env) });
|
|
89
|
+
const cache = readRemoteCache(dir);
|
|
90
|
+
const base = { ...cache, checkedAt: now };
|
|
91
|
+
// The cached bundle counts only while it still verifies: after a key rotation
|
|
92
|
+
// the same version has to be fetched again rather than skipped as current.
|
|
93
|
+
const cachedOk = cache.bundle && evaluateBundle(cache.bundle, { version, keys }).ok;
|
|
94
|
+
const have = cachedOk ? cache.bundle.version : 0;
|
|
95
|
+
try {
|
|
96
|
+
const head = await call('presets_version');
|
|
97
|
+
if (!head || !Number.isInteger(head.version)) {
|
|
98
|
+
writeCache({ ...base, lastError: null }, dir);
|
|
99
|
+
return { status: 'none' };
|
|
100
|
+
}
|
|
101
|
+
if (!force && head.version === have) {
|
|
102
|
+
writeCache({ ...base, lastError: null }, dir);
|
|
103
|
+
return { status: 'current', version: have };
|
|
104
|
+
}
|
|
105
|
+
if (!force && cache.rejected && cache.rejected.version === head.version && cache.rejected.cli === version
|
|
106
|
+
&& cache.rejected.keys === keys.length) {
|
|
107
|
+
writeCache({ ...base, lastError: null }, dir);
|
|
108
|
+
return { status: 'rejected', version: head.version, reason: cache.rejected.reason };
|
|
109
|
+
}
|
|
110
|
+
const bundle = await call('presets_published');
|
|
111
|
+
if (!bundle) {
|
|
112
|
+
writeCache({ ...base, lastError: null }, dir);
|
|
113
|
+
return { status: 'none' };
|
|
114
|
+
}
|
|
115
|
+
if (Number.isInteger(bundle.version) && bundle.version < have) {
|
|
116
|
+
const rejected = { version: bundle.version, reason: 'older', at: now, cli: version, keys: keys.length };
|
|
117
|
+
writeCache({ ...base, rejected, lastError: null }, dir);
|
|
118
|
+
return { status: 'rejected', version: bundle.version, reason: 'older' };
|
|
119
|
+
}
|
|
120
|
+
const ev = evaluateBundle(bundle, { version, keys });
|
|
121
|
+
if (!ev.ok) {
|
|
122
|
+
const rejected = { version: Number.isInteger(bundle.version) ? bundle.version : null, reason: ev.reason, at: now, cli: version, keys: keys.length };
|
|
123
|
+
writeCache({ ...base, rejected, lastError: null }, dir);
|
|
124
|
+
return { status: 'rejected', version: rejected.version, reason: ev.reason };
|
|
125
|
+
}
|
|
126
|
+
writeCache({ checkedAt: now, fetchedAt: now, version: bundle.version, bundle, rejected: null, lastError: null }, dir);
|
|
127
|
+
return { status: 'updated', version: bundle.version };
|
|
128
|
+
} catch (e) {
|
|
129
|
+
debug('preset-sync', e);
|
|
130
|
+
writeCache({ ...base, lastError: { message: String(e && e.message || e).slice(0, 200), at: now } }, dir);
|
|
131
|
+
return { status: 'error', reason: String(e && e.message || e) };
|
|
132
|
+
}
|
|
133
|
+
}
|