@smartmemory/compose 0.3.6-beta → 0.3.8
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/skills/compose/SKILL.md +42 -88
- package/.compose-deps.json +1 -13
- package/README.md +72 -5
- package/bin/compose.js +754 -347
- package/bin/git-hooks/pre-push.template +29 -0
- package/bin/judgment-import.js +7 -0
- package/bin/judgment-migrate.js +387 -0
- package/contracts/comp-obs-contract.schema.json +9 -3
- package/contracts/feature-json.schema.json +5 -0
- package/contracts/fluid-record.schema.json +209 -0
- package/contracts/judgment-record.schema.json +425 -4
- package/contracts/lifecycle-backfill.schema.json +322 -0
- package/dist/assets/App-Z4MU-H_F.js +916 -0
- package/dist/assets/_baseUniq-ClWoCPFl.js +1 -0
- package/dist/assets/arc-DY26UIVo.js +1 -0
- package/dist/assets/architectureDiagram-Q4EWVU46-6Ggq4DqJ.js +36 -0
- package/dist/assets/blockDiagram-DXYQGD6D-CH3Ked0l.js +132 -0
- package/dist/assets/{browser-BSM23If2.js → browser-BWkrenen.js} +6 -6
- package/dist/assets/{c4Diagram-LMCZKHZV-DZf45Fbz.js → c4Diagram-AHTNJAMY-Bk8dYilu.js} +1 -1
- package/dist/assets/channel-SnZzzh7k.js +1 -0
- package/dist/assets/{chunk-JWPE2WC7-_7ujgd_Q.js → chunk-4BX2VUAB-BMR0XaAQ.js} +1 -1
- package/dist/assets/chunk-4TB4RGXK-JytR14a9.js +206 -0
- package/dist/assets/{chunk-XXDRQBXY-DfdVhbmA.js → chunk-55IACEB6-B4Q97BCP.js} +1 -1
- package/dist/assets/{chunk-VR4S4FIN-Dt9NZ67m.js → chunk-EDXVE4YY-R_qarkSf.js} +1 -1
- package/dist/assets/{chunk-5VM5RSS4-BY4_PV5H.js → chunk-FMBD7UC4-C9s7KR9m.js} +1 -1
- package/dist/assets/chunk-OYMX7WX6-BySQzVxc.js +231 -0
- package/dist/assets/{chunk-2Q5K7J3B-Dn1spZYu.js → chunk-QZHKN3VN-DdpSYZsW.js} +1 -1
- package/dist/assets/{chunk-32BRIVSS-pURGrJDk.js → chunk-YZCP3GAM-iE_tzriw.js} +1 -1
- package/dist/assets/classDiagram-6PBFFD2Q-CBu92dSH.js +1 -0
- package/dist/assets/classDiagram-v2-HSJHXN6E-CBu92dSH.js +1 -0
- package/dist/assets/clone-DgklGjHm.js +1 -0
- package/dist/assets/{cose-bilkent-JH36ORCC-BieYif4o.js → cose-bilkent-S5V4N54A-BdlU6ZX_.js} +1 -1
- package/dist/assets/dagre-KV5264BT-Cp3F5KTn.js +4 -0
- package/dist/assets/diagram-5BDNPKRD-DiR6_2q_.js +10 -0
- package/dist/assets/diagram-G4DWMVQ6-w0i-p5HX.js +24 -0
- package/dist/assets/diagram-MMDJMWI5-tIHhwUv3.js +43 -0
- package/dist/assets/diagram-TYMM5635-BAeY3B19.js +24 -0
- package/dist/assets/erDiagram-SMLLAGMA-Ckx_Knko.js +85 -0
- package/dist/assets/flowDiagram-DWJPFMVM-DeoNka6J.js +162 -0
- package/dist/assets/ganttDiagram-T4ZO3ILL-BmGnFbEg.js +292 -0
- package/dist/assets/gitGraphDiagram-UUTBAWPF-Dk48IHsx.js +106 -0
- package/dist/assets/graph-BNzKGvoy.js +1 -0
- package/dist/assets/graph-CI_1htl0.js +331 -0
- package/dist/assets/index-BEfrNBp8.js +123 -0
- package/dist/assets/index-yyrA5OZd.css +1 -0
- package/dist/assets/infoDiagram-42DDH7IO-BRf827i0.js +2 -0
- package/dist/assets/{ishikawaDiagram-FXEZZL3T-CzEB9fQS.js → ishikawaDiagram-UXIWVN3A-0kCZaeCM.js} +5 -5
- package/dist/assets/{journeyDiagram-5HDEW3XC-Bz8TCdz2.js → journeyDiagram-VCZTEJTY-rvU7ayRt.js} +1 -1
- package/dist/assets/{kanban-definition-HUTT4EX6-tozrMoV_.js → kanban-definition-6JOO6SKY-DpQwX1C5.js} +7 -7
- package/dist/assets/katex-DkKDou_j.js +257 -0
- package/dist/assets/layout-BI8cXFPI.js +1 -0
- package/dist/assets/{linear-Ck7gpa5N.js → linear-a0glcDiw.js} +1 -1
- package/dist/assets/min-vPHfnXcC.js +1 -0
- package/dist/assets/{mindmap-definition-LN4V7U3C-DTcHO0DJ.js → mindmap-definition-QFDTVHPH-D14eF-7C.js} +7 -7
- package/dist/assets/mobile-B7m9EO9D.js +17 -0
- package/dist/assets/pieDiagram-DEJITSTG-Cno-gETh.js +30 -0
- package/dist/assets/quadrantDiagram-34T5L4WZ-BUQM1Hfm.js +7 -0
- package/dist/assets/{requirementDiagram-TGXJPOKE-bnI2zJeT.js → requirementDiagram-MS252O5E-pOXlN2-q.js} +3 -3
- package/dist/assets/sankeyDiagram-XADWPNL6-Crynd3_b.js +10 -0
- package/dist/assets/sequenceDiagram-FGHM5R23-D9fZdCM8.js +157 -0
- package/dist/assets/stateDiagram-FHFEXIEX-CW9qVec8.js +1 -0
- package/dist/assets/stateDiagram-v2-QKLJ7IA2-DkVLzHbY.js +1 -0
- package/dist/assets/{timeline-definition-FHXFAJF6-D267GQFF.js → timeline-definition-GMOUNBTQ-BcHzhm_8.js} +3 -3
- package/dist/assets/vennDiagram-DHZGUBPP-BfytJcWk.js +34 -0
- package/dist/assets/wardley-RL74JXVD-DLj-IjyB.js +162 -0
- package/dist/assets/wardleyDiagram-NUSXRM2D-Ds0Ue68c.js +20 -0
- package/dist/assets/xychartDiagram-5P7HB3ND-vjWDXFL6.js +7 -0
- package/dist/index.html +3 -3
- package/lib/agent-string.js +7 -5
- package/lib/append-integrity.js +81 -0
- package/lib/backfill-evidence.js +109 -0
- package/lib/bug-escalation.js +39 -4
- package/lib/build-stream-schema.js +3 -1
- package/lib/build-stream-writer.js +25 -0
- package/lib/build.js +1624 -195
- package/lib/canon-guard.js +245 -0
- package/lib/canon-override.js +196 -0
- package/lib/canon-registry.js +291 -0
- package/lib/cli-commands.js +144 -0
- package/lib/codex-preflight.js +50 -15
- package/lib/colleague/context.js +215 -0
- package/lib/colleague/writeback.js +95 -0
- package/lib/completion-gate.js +1421 -0
- package/lib/completion-writer.js +47 -47
- package/lib/consumer-fanout.js +105 -11
- package/lib/coverage-gate.js +200 -0
- package/lib/dir-lock.js +170 -0
- package/lib/dispatch-ledger.js +301 -0
- package/lib/dispatch-metrics.js +236 -0
- package/lib/experiment-judge.js +6 -1
- package/lib/feature-json.js +1 -1
- package/lib/feature-reconciler.js +8 -0
- package/lib/feature-validator.js +64 -1
- package/lib/feature-writer.js +66 -2
- package/lib/fluid/factory.js +167 -0
- package/lib/fluid/ideabox-dates.js +73 -0
- package/lib/fluid/ideabox-migrate.js +154 -0
- package/lib/fluid/ideabox-ops.js +585 -0
- package/lib/fluid/ideabox-view.js +146 -0
- package/lib/fluid/import-ideabox.js +186 -0
- package/lib/fluid/local-provider.js +606 -0
- package/lib/fluid/provider.js +684 -0
- package/lib/fluid/record-shape.js +214 -0
- package/lib/fluid/record-store.js +328 -0
- package/lib/fluid/render-ideabox.js +261 -0
- package/lib/fluid/schema.js +40 -0
- package/lib/fluid/smartmemory-provider.js +1695 -0
- package/lib/gsd.js +63 -14
- package/lib/guard-cli.js +175 -0
- package/lib/guard-custody.js +141 -0
- package/lib/guard-descriptors.js +530 -0
- package/lib/guard-enrol.js +254 -0
- package/lib/health-score.js +1 -1
- package/lib/hooks-status.js +32 -3
- package/lib/ideabox-cli.js +315 -0
- package/lib/ideabox.js +121 -21
- package/lib/judgment/store/index.js +166 -0
- package/lib/judgment/store/records.js +184 -25
- package/lib/judgment/trace.js +380 -0
- package/lib/judgment-attest.js +259 -0
- package/lib/judgment-decision-write.js +277 -0
- package/lib/judgment-decisions.js +466 -0
- package/lib/judgment-gen.js +375 -22
- package/lib/judgment-verify.js +153 -0
- package/lib/judgment-writer.js +2842 -262
- package/lib/lane-gate.js +2 -0
- package/lib/lifecycle-modes.js +4 -4
- package/lib/lineage.js +400 -0
- package/lib/local-claude-connector.js +250 -54
- package/lib/maya-client.js +302 -0
- package/lib/maya-config.js +53 -0
- package/lib/maya-identity.js +283 -0
- package/lib/mcp-enforcement.js +21 -35
- package/lib/migrate-anon.js +5 -0
- package/lib/migrate-roadmap.js +15 -0
- package/lib/new.js +13 -1
- package/lib/pipeline-compat.js +104 -0
- package/lib/policy-catalog.js +295 -0
- package/lib/policy-check.js +0 -0
- package/lib/process-termination.js +98 -0
- package/lib/resolve-workspace.js +5 -1
- package/lib/result-normalizer.js +428 -153
- package/lib/review-normalize.js +4 -0
- package/lib/roadmap-errors.js +65 -0
- package/lib/roadmap-preservers.js +24 -4
- package/lib/roadmap-residue.js +299 -0
- package/lib/smartmemory-client.js +614 -78
- package/lib/smartmemory-config.js +54 -0
- package/lib/smartmemory-ingest.js +19 -2
- package/lib/step-prompt.js +7 -6
- package/lib/stratum-engine.js +53 -4
- package/lib/stratum-mcp-client.js +391 -31
- package/lib/test-bootstrap.js +31 -0
- package/lib/tool-inventory.js +122 -0
- package/lib/version-check.js +91 -19
- package/lib/vision-writer.js +88 -1
- package/package.json +7 -6
- package/pipelines/bug-fix.stratum.yaml +205 -211
- package/pipelines/build-quick.profiles.json +12 -0
- package/pipelines/build-quick.stratum.yaml +263 -350
- package/pipelines/content.stratum.yaml +81 -77
- package/pipelines/coverage-sweep.stratum.yaml +49 -30
- package/pipelines/plan.stratum.yaml +76 -86
- package/pipelines/refactor.stratum.yaml +125 -125
- package/pipelines/research.stratum.yaml +56 -58
- package/pipelines/review-fix.profiles.json +6 -0
- package/pipelines/review-fix.stratum.yaml +110 -83
- package/presets/team-feature.profiles.json +6 -0
- package/presets/team-feature.stratum.yaml +93 -66
- package/presets/team-research.profiles.json +6 -0
- package/presets/team-research.stratum.yaml +89 -80
- package/presets/team-review.profiles.json +8 -0
- package/presets/team-review.stratum.yaml +98 -80
- package/scripts/cost-census.mjs +70 -0
- package/scripts/guard-sign/compose-guard-sign.sh +62 -0
- package/server/agent-health.js +22 -0
- package/server/agent-hooks.js +14 -1
- package/server/agent-server.js +5 -248
- package/server/agent-spawn.js +3 -4
- package/server/agent-workspace.js +294 -0
- package/server/build-routes.js +6 -5
- package/server/build-stream-bridge.js +53 -0
- package/server/cc-session-watcher.js +4 -1
- package/server/coalescing-buffer.js +7 -1
- package/server/completion-projection.js +228 -0
- package/server/compose-mcp-tools.js +124 -24
- package/server/compose-mcp.js +91 -790
- package/server/decision-event-emit.js +41 -2
- package/server/decision-event-id.js +17 -0
- package/server/decision-events-snapshot.js +3 -0
- package/server/design-routes.js +14 -8
- package/server/feature-scan.js +76 -2
- package/server/file-watcher.js +170 -21
- package/server/ideabox-routes.js +166 -224
- package/server/index.js +70 -100
- package/server/lifecycle-guard.js +240 -10
- package/server/lifecycle-phase-history.js +276 -0
- package/server/maya-routes.js +507 -0
- package/server/mcp-tool-defs.js +940 -0
- package/server/mcp-tool-policy.js +35 -3
- package/server/model-tiers.js +22 -5
- package/server/pipeline-routes.js +21 -11
- package/server/project-root.js +58 -19
- package/server/remote-utils.js +3 -1
- package/server/schema-validator.js +7 -1
- package/server/session-manager.js +5 -6
- package/server/session-routes.js +3 -1
- package/server/stratum-client.js +57 -10
- package/server/stratum-sync.js +6 -3
- package/server/summarizer.js +3 -4
- package/server/supervisor.js +0 -1
- package/server/vision-routes.js +208 -98
- package/server/vision-server.js +86 -23
- package/server/vision-store.js +60 -6
- package/server/vision-utils.js +3 -4
- package/server/workspace-activity.js +18 -0
- package/server/workspace-middleware.js +2 -2
- package/server/workspace-runtime.js +243 -0
- package/server/worktree-gc.js +1 -0
- package/dist/assets/App-BG3ngu8H.js +0 -896
- package/dist/assets/abnfDiagram-VRR7QNED-CjB_sD3D.js +0 -1
- package/dist/assets/arc-_v4hR_uD.js +0 -1
- package/dist/assets/architectureDiagram-ZJ3FMSHR-DreJmzXQ.js +0 -36
- package/dist/assets/blockDiagram-677ZJIJ3-BG9-c0O1.js +0 -132
- package/dist/assets/channel-B3U5wFAT.js +0 -1
- package/dist/assets/chunk-EX3LRPZG-DdELs1qP.js +0 -231
- package/dist/assets/chunk-MOJQB5TN-D-ky35G-.js +0 -88
- package/dist/assets/chunk-RYQCIY6F-Dag_kVlO.js +0 -1
- package/dist/assets/chunk-V7JOEXUC-BtewURat.js +0 -206
- package/dist/assets/classDiagram-OUVF2IWQ-B6fCN-ht.js +0 -1
- package/dist/assets/classDiagram-v2-EOCWNBFH-B6fCN-ht.js +0 -1
- package/dist/assets/cynefin-VYW2F7L2-CT2BA6KE.js +0 -178
- package/dist/assets/cynefinDiagram-TSTJHNR4-Bh6exbyg.js +0 -62
- package/dist/assets/dagre-VKFMJZFB-aXMLSmQL.js +0 -4
- package/dist/assets/diagram-FQU43EPY-Dr7JAOuQ.js +0 -3
- package/dist/assets/diagram-G47NLZAW-DUvA3FQK.js +0 -24
- package/dist/assets/diagram-NH7WQ7WH-BQUARqcu.js +0 -24
- package/dist/assets/diagram-OA4YK3LP-dDUc1zHi.js +0 -30
- package/dist/assets/diagram-WEI45ONY-B2h5Qlb1.js +0 -41
- package/dist/assets/ebnfDiagram-CCIWWBDH-DThRGupB.js +0 -1
- package/dist/assets/erDiagram-Q63AITRT-BUCsprO2.js +0 -85
- package/dist/assets/flowDiagram-23GEKE2U-DXtNNi6r.js +0 -156
- package/dist/assets/ganttDiagram-NO4QXBWP-D4zbBHh_.js +0 -292
- package/dist/assets/gitGraphDiagram-IHSO6WYX-DpoQws0W.js +0 -106
- package/dist/assets/graph-BXPQrYYB.js +0 -331
- package/dist/assets/graph-C9eacEi8.js +0 -1
- package/dist/assets/index-3ZH5eMcZ.js +0 -119
- package/dist/assets/index-LIwREYgH.css +0 -1
- package/dist/assets/infoDiagram-FWYZ7A6U-Bbas2GAo.js +0 -2
- package/dist/assets/katex-C5jXJg4s.js +0 -257
- package/dist/assets/layout-DEXfKzaS.js +0 -1
- package/dist/assets/map-Czzmt4hB.js +0 -1
- package/dist/assets/mobile-CaoXUwAr.js +0 -17
- package/dist/assets/pegDiagram-2B236MQR-CHiINrNy.js +0 -1
- package/dist/assets/pieDiagram-ENE6RG2P-CfS4YFlR.js +0 -39
- package/dist/assets/quadrantDiagram-ABIIQ3AL-CadesS9w.js +0 -7
- package/dist/assets/railroadDiagram-RFXS5EU6-CgWEspBN.js +0 -1
- package/dist/assets/sankeyDiagram-HTMAVEWB-YWKFgOGw.js +0 -40
- package/dist/assets/sequenceDiagram-DBY2YBRQ-BvkNOyF9.js +0 -162
- package/dist/assets/sizeCapture-X5ZJPWSS-DlFPA2yO.js +0 -1
- package/dist/assets/stateDiagram-2N3HPSRC-h8NIx0kQ.js +0 -1
- package/dist/assets/stateDiagram-v2-6OUMAXLB-DjPgZtJ9.js +0 -1
- package/dist/assets/swimlanes-5IMT3BWC-CT5n22kG.js +0 -2
- package/dist/assets/swimlanesDiagram-G3AALYLV-Dn318Bhq.js +0 -8
- package/dist/assets/vennDiagram-L72KCM5P-Dj-wWLYG.js +0 -34
- package/dist/assets/wardleyDiagram-EHGQE667-BxCeYxkG.js +0 -78
- package/dist/assets/xychartDiagram-FW5EYKEG-DMFqWn7z.js +0 -7
- package/lib/staleness.js +0 -87
- package/server/ideabox-cache.js +0 -77
package/lib/gsd.js
CHANGED
|
@@ -26,7 +26,7 @@ import { validateBoundaryMap } from './boundary-map.js';
|
|
|
26
26
|
import { enrichTaskGraph } from './gsd-decompose-enrich.js';
|
|
27
27
|
import { buildTaskDescription } from './gsd-prompt.js';
|
|
28
28
|
import { writeAll, validate as validateTaskResult, read as readBlackboard } from './gsd-blackboard.js';
|
|
29
|
-
import { executeShipStep, runConsumerIssuance, ConsumerStuckError, filesOwnedConflict, toEngineUsage } from './build.js';
|
|
29
|
+
import { executeShipStep, toPhaseResultOutput, runConsumerIssuance, ConsumerStuckError, filesOwnedConflict, toEngineUsage, reportUsageReceipts } from './build.js';
|
|
30
30
|
import {
|
|
31
31
|
ConsumerFanoutArtifacts,
|
|
32
32
|
ConsumerMergeDecisionError,
|
|
@@ -175,6 +175,9 @@ export async function runGsd(featureCode, opts = {}) {
|
|
|
175
175
|
const stratum = opts.stratum ?? new StratumMcpClient();
|
|
176
176
|
const ownsStratum = !opts.stratum;
|
|
177
177
|
if (ownsStratum) await stratum.connect(resolveStratumMcpConnection(cwd));
|
|
178
|
+
const receiptsMode = typeof stratum.hasTool === 'function'
|
|
179
|
+
? await stratum.hasTool('stratum_usage_report')
|
|
180
|
+
: false;
|
|
178
181
|
// COMP-GSD-4: ownership flag — release the resume lock in finally ONLY if THIS
|
|
179
182
|
// process successfully claimed it (set below). Prevents (a) a non-resume run
|
|
180
183
|
// from clobbering a concurrent resume's valid claim and (b) a claim-race loser
|
|
@@ -231,6 +234,7 @@ export async function runGsd(featureCode, opts = {}) {
|
|
|
231
234
|
// docs unless context.filesChanged is provided.
|
|
232
235
|
stepCtx = {
|
|
233
236
|
stratum, cwd, featureCode, blueprintText, gateCommands, preMergeGate,
|
|
237
|
+
receiptsMode,
|
|
234
238
|
localSpec, localSpecDigest,
|
|
235
239
|
filesChanged: [],
|
|
236
240
|
stuckDetector,
|
|
@@ -289,6 +293,7 @@ export async function runGsd(featureCode, opts = {}) {
|
|
|
289
293
|
pre_merge_gate: preMergeGate,
|
|
290
294
|
}, { workspaceRoot: cwd });
|
|
291
295
|
const flowId = response.runId;
|
|
296
|
+
stepCtx.flowId = flowId;
|
|
292
297
|
flushState(stepCtx, { flowId, phase: 'decompose' });
|
|
293
298
|
emitPhaseOnce(stepCtx, 'decompose'); // COMP-GSD-7-EVENTLOG
|
|
294
299
|
|
|
@@ -518,7 +523,7 @@ async function runOneStep(response, ctx) {
|
|
|
518
523
|
// by definition a GSD task, so timing.json + diffs/<id>.diff are written
|
|
519
524
|
// for the milestone report. The build consumer path omits it (no marker),
|
|
520
525
|
// so build-mode fanout stays byte-identical.
|
|
521
|
-
context: { cwd, featureCode, gsd: true, gsdTaskId: consumerItem?.id, filesChanged: ctx.filesChanged, onUsage: (usage) => recordTsAgentUsage(ctx, usage), taskResultPath },
|
|
526
|
+
context: { cwd, projectCwd: cwd, featureCode, flowId, receiptsMode: ctx.receiptsMode, gsd: true, gsdTaskId: consumerItem?.id, filesChanged: ctx.filesChanged, onUsage: (usage, meta) => recordTsAgentUsage(ctx, usage, meta), taskResultPath },
|
|
522
527
|
// runAndNormalize narrates via progress.debug/warn/info/toolUse — gsd
|
|
523
528
|
// has no cockpit, so a COMPLETE no-op (not just stepStart/stepDone) is
|
|
524
529
|
// required or the item throws "progress.debug is not a function".
|
|
@@ -554,7 +559,14 @@ async function runOneStep(response, ctx) {
|
|
|
554
559
|
'',
|
|
555
560
|
null,
|
|
556
561
|
);
|
|
557
|
-
|
|
562
|
+
// COMP-SHIP-CONTRACT: narrow to PhaseResult before reporting. The raw
|
|
563
|
+
// return carries `commit`/`filesChanged`/`testsAttested`, which the
|
|
564
|
+
// strict contract rejects — attempt 1 was failing on unrecognized keys and
|
|
565
|
+
// only "succeeding" because attempt 2 re-ran ship, found nothing left to
|
|
566
|
+
// stage, and returned the field-less "already committed" shape.
|
|
567
|
+
return stratum.stepDone(
|
|
568
|
+
flowId, step.id, { output: toPhaseResultOutput(result) }, step.dispatchToken,
|
|
569
|
+
);
|
|
558
570
|
}
|
|
559
571
|
if (step.id === 'decompose_gsd' && ctx.resumeTaskGraph) {
|
|
560
572
|
ctx.lastTaskGraph = ctx.resumeTaskGraph;
|
|
@@ -565,12 +577,45 @@ async function runOneStep(response, ctx) {
|
|
|
565
577
|
|
|
566
578
|
// D2(b): dispatch via agentRun (not runAgentText) so the TS complete
|
|
567
579
|
// envelope's usage is captured and debited into the cumulative ledger.
|
|
568
|
-
|
|
580
|
+
let runOut;
|
|
581
|
+
try {
|
|
582
|
+
runOut = await stratum.agentRun(step.agent ?? 'claude', step.do ?? '', {
|
|
583
|
+
cwd,
|
|
584
|
+
telemetry: {
|
|
585
|
+
site: 'gsd',
|
|
586
|
+
project_cwd: cwd,
|
|
587
|
+
feature_code: featureCode,
|
|
588
|
+
step_id: step.id,
|
|
589
|
+
...(typeof step.attempt === 'number' ? { attempt: step.attempt } : {}),
|
|
590
|
+
},
|
|
591
|
+
});
|
|
592
|
+
} catch (error) {
|
|
593
|
+
if (error?.usage) {
|
|
594
|
+
await recordTsAgentUsage(ctx, error.usage, {
|
|
595
|
+
stepId: step.id,
|
|
596
|
+
source: 'main',
|
|
597
|
+
dispatchId: error.dispatchId,
|
|
598
|
+
});
|
|
599
|
+
}
|
|
600
|
+
throw error;
|
|
601
|
+
}
|
|
569
602
|
const text = runOut?.text ?? '';
|
|
570
|
-
|
|
603
|
+
const directUsage = runOut?.usage && typeof runOut.usage === 'object'
|
|
604
|
+
? {
|
|
605
|
+
...runOut.usage,
|
|
606
|
+
dispatch_id: runOut.dispatchId,
|
|
607
|
+
model: runOut.telemetry?.model ?? runOut.usage.model ?? 'unknown',
|
|
608
|
+
...(runOut.telemetry?.effort ? { effort: runOut.telemetry.effort } : {}),
|
|
609
|
+
duration_ms: runOut.telemetry?.durationMs ?? runOut.usage.duration_ms ?? runOut.usage.ms ?? 0,
|
|
610
|
+
...(['reported', 'estimated'].includes(runOut.usage.usd_source)
|
|
611
|
+
? { usd_source: runOut.usage.usd_source }
|
|
612
|
+
: {}),
|
|
613
|
+
}
|
|
614
|
+
: runOut?.usage;
|
|
615
|
+
await recordTsAgentUsage(ctx, directUsage, { stepId: step.id, source: 'main' });
|
|
571
616
|
// V1: also report usage in the step_done envelope so the ENGINE debits its
|
|
572
617
|
// flow budget (not just compose's cumulative ledger above).
|
|
573
|
-
const ordinaryUsage = toEngineUsage(runOut?.usage);
|
|
618
|
+
const ordinaryUsage = ctx.receiptsMode ? null : toEngineUsage(runOut?.usage);
|
|
574
619
|
let result;
|
|
575
620
|
try {
|
|
576
621
|
result = parseJsonFromText(text);
|
|
@@ -1411,21 +1456,25 @@ const NOOP_PROGRESS = Object.freeze({
|
|
|
1411
1456
|
* shape from runAndNormalize ({input_tokens, output_tokens, cost_usd,
|
|
1412
1457
|
* duration_ms}) or the raw TS complete usage ({tokens, usd, ms}).
|
|
1413
1458
|
*/
|
|
1414
|
-
export function recordTsAgentUsage(ctx, usage) {
|
|
1459
|
+
export async function recordTsAgentUsage(ctx, usage, meta) {
|
|
1415
1460
|
if (!usage || !ctx?.cwd || !ctx?.featureCode) return;
|
|
1416
1461
|
const tokens = usage.tokens ?? ((usage.input_tokens ?? 0) + (usage.output_tokens ?? 0));
|
|
1417
1462
|
const costUsd = usage.cost_usd ?? usage.usd ?? 0;
|
|
1418
1463
|
const timeMs = usage.duration_ms ?? usage.ms ?? 0;
|
|
1419
|
-
if (tokens
|
|
1420
|
-
|
|
1464
|
+
if (tokens !== 0 || costUsd !== 0 || timeMs !== 0) {
|
|
1465
|
+
recordGsdUsage(join(ctx.cwd, '.compose'), ctx.featureCode, { tokens, costUsd, timeMs, dispatches: 1 });
|
|
1466
|
+
}
|
|
1421
1467
|
// G4: remember what THIS run recorded incrementally so the terminal/budget-halt
|
|
1422
1468
|
// fold (recordGsdUsageFromState) records only the engine-only delta, never
|
|
1423
1469
|
// double-counting these envelope-reported invocations.
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1470
|
+
if (tokens !== 0 || costUsd !== 0 || timeMs !== 0) {
|
|
1471
|
+
ctx.recordedUsage ??= { tokens: 0, costUsd: 0, dispatches: 0, timeMs: 0 };
|
|
1472
|
+
ctx.recordedUsage.tokens += tokens;
|
|
1473
|
+
ctx.recordedUsage.costUsd += costUsd;
|
|
1474
|
+
ctx.recordedUsage.timeMs += timeMs;
|
|
1475
|
+
ctx.recordedUsage.dispatches += 1;
|
|
1476
|
+
}
|
|
1477
|
+
return reportUsageReceipts(ctx, usage, meta);
|
|
1429
1478
|
}
|
|
1430
1479
|
|
|
1431
1480
|
/**
|
package/lib/guard-cli.js
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import { execFile as defaultExecFile } from 'node:child_process';
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
3
|
+
import { readFile as defaultReadFile } from 'node:fs/promises';
|
|
4
|
+
import { createRequire } from 'node:module';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
|
|
7
|
+
import { acquireDirLock as defaultAcquireDirLock } from './dir-lock.js';
|
|
8
|
+
import { CUSTODY_TIMEOUT_MS, custodyBackend, custodySign, custodyStatus, DESCRIPTOR_LOCK_TIMEOUT_MS } from './guard-custody.js';
|
|
9
|
+
import {
|
|
10
|
+
currentGeneration as defaultCurrentGeneration, descriptorIdFor, ensureSignedDescriptors as defaultEnsureSignedDescriptors,
|
|
11
|
+
enumerateRegisteredResources as defaultEnumerateRegisteredResources, prepareUnsignedCandidate as defaultPrepareUnsignedCandidate,
|
|
12
|
+
pruneGenerations as defaultPruneGenerations, verifyGeneration as defaultVerifyGeneration,
|
|
13
|
+
} from './guard-descriptors.js';
|
|
14
|
+
import { guardDescriptors as defaultGuardDescriptors } from '../server/stratum-client.js';
|
|
15
|
+
import { runEnrol } from './guard-enrol.js';
|
|
16
|
+
|
|
17
|
+
const require = createRequire(import.meta.url);
|
|
18
|
+
const { parseAllowedSigners } = await import(require.resolve('@smartmemory/stratum/dist/guard/sshsig.js'));
|
|
19
|
+
|
|
20
|
+
const LOCK_RELATIVE = path.join('.compose', 'data', 'locks', 'guard-descriptors');
|
|
21
|
+
const LEGACY_FLAT = path.join('.compose', 'guard-upgrades.json');
|
|
22
|
+
const NAMESPACE = 'stratum-guard-descriptors';
|
|
23
|
+
|
|
24
|
+
function descriptorLock(workspaceRoot) { return path.join(workspaceRoot, LOCK_RELATIVE); }
|
|
25
|
+
|
|
26
|
+
function makeResult(status, lines, extra = {}) { return { status, lines, ...extra }; }
|
|
27
|
+
|
|
28
|
+
async function withLock(workspaceRoot, deps, operation) {
|
|
29
|
+
const release = await (deps.acquireDirLock ?? defaultAcquireDirLock)(descriptorLock(workspaceRoot), { timeoutMs: DESCRIPTOR_LOCK_TIMEOUT_MS });
|
|
30
|
+
try { return await operation(); } finally { release(); }
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function asWorkspace(input) {
|
|
34
|
+
return typeof input === 'string' ? input : input?.workspaceRoot;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function manualSignLine(file) {
|
|
38
|
+
return `ssh-keygen -Y sign -f ~/.stratum/guard-signing -n ${NAMESPACE} ${file}`;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function signedLine(signature, count) {
|
|
42
|
+
const match = /^verified: signed by (.+) \(([^)]+)\)$/.exec(signature || '');
|
|
43
|
+
if (!match) return null;
|
|
44
|
+
return `signed by ${match[1]} (${match[2]}), ${count} descriptor(s), verified`;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async function descriptorCount(file, readFile) {
|
|
48
|
+
try {
|
|
49
|
+
const parsed = JSON.parse(await readFile(file, 'utf8'));
|
|
50
|
+
return Array.isArray(parsed?.descriptors) ? parsed.descriptors.length : 0;
|
|
51
|
+
} catch { return 0; }
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Generate/sign descriptor generations. This returns lines; the bin owns stdout. */
|
|
55
|
+
export async function runGuardDescriptors(input, deps = {}) {
|
|
56
|
+
const workspaceRoot = asWorkspace(input);
|
|
57
|
+
if (!workspaceRoot) throw new Error('workspaceRoot is required');
|
|
58
|
+
try {
|
|
59
|
+
return await withLock(workspaceRoot, deps, async () => {
|
|
60
|
+
const backend = (deps.custodyBackend ?? custodyBackend)();
|
|
61
|
+
const enumerate = deps.enumerateRegisteredResources ?? defaultEnumerateRegisteredResources;
|
|
62
|
+
const resources = await enumerate(workspaceRoot);
|
|
63
|
+
const needChecksums = resources.map((resource) => resource.from_checksum ?? resource.fromChecksum).filter(Boolean);
|
|
64
|
+
const verifier = deps.guardDescriptors ?? defaultGuardDescriptors;
|
|
65
|
+
const ensured = await (deps.ensureSignedDescriptors ?? defaultEnsureSignedDescriptors)({
|
|
66
|
+
workspaceRoot,
|
|
67
|
+
needChecksums,
|
|
68
|
+
custody: { sign: (request) => (deps.custodySign ?? custodySign)(request) },
|
|
69
|
+
verifier,
|
|
70
|
+
enumerate,
|
|
71
|
+
});
|
|
72
|
+
if (ensured.status === 'refused') {
|
|
73
|
+
// Only the plain "no custody available" refusal falls back to the manual
|
|
74
|
+
// unsigned-candidate path. Any other refusal (e.g. an existing generation
|
|
75
|
+
// whose signature does not verify) must be returned as-is — falling back
|
|
76
|
+
// here would print a `ssh-keygen -Y sign` line targeting a path that
|
|
77
|
+
// already carries an invalid `.sig`, which is exactly the "sign over an
|
|
78
|
+
// invalid generation" path S1 forbids.
|
|
79
|
+
if (backend === 'none' && ensured.message === 'no signing custody on this platform') {
|
|
80
|
+
const candidate = await (deps.prepareUnsignedCandidate ?? defaultPrepareUnsignedCandidate)(workspaceRoot, { enumerate });
|
|
81
|
+
return makeResult('manual', [manualSignLine(candidate.path)], { path: candidate.path, sha: candidate.sha });
|
|
82
|
+
}
|
|
83
|
+
return makeResult('refused', [ensured.message, ensured.hint].filter(Boolean), ensured);
|
|
84
|
+
}
|
|
85
|
+
if (ensured.status === 'fresh') return makeResult('fresh', [`fresh: ${ensured.path}`], ensured);
|
|
86
|
+
const inspected = await verifier(ensured.path);
|
|
87
|
+
const verdict = signedLine(inspected?.signature, await descriptorCount(ensured.path, deps.readFile ?? defaultReadFile));
|
|
88
|
+
if (!verdict || inspected?.status !== 'ok' || inspected?.group_or_world_writable !== false) {
|
|
89
|
+
return makeResult('refused', [`signature did not verify: ${inspected?.signature || 'no verifier verdict'}`], { code: 'upgrade_descriptor_unavailable', path: ensured.path });
|
|
90
|
+
}
|
|
91
|
+
return makeResult('signed', [verdict], ensured);
|
|
92
|
+
});
|
|
93
|
+
} catch (error) {
|
|
94
|
+
return makeResult('refused', [error?.message || String(error)], { code: 'upgrade_descriptor_unavailable' });
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Explicit signing uses the same safe generation path as descriptors. */
|
|
99
|
+
export async function runGuardSign(input, deps = {}) {
|
|
100
|
+
return runGuardDescriptors(input, deps);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export async function runGuardEnrol(options = {}, deps = {}) {
|
|
104
|
+
return runEnrol(options, deps);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function runCommand(execFile, file, args, options) {
|
|
108
|
+
return new Promise((resolve) => {
|
|
109
|
+
try {
|
|
110
|
+
execFile(file, args, options, (error, stdout = '', stderr = '') => resolve({ error, stdout, stderr }));
|
|
111
|
+
} catch (error) { resolve({ error, stdout: '', stderr: '' }); }
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async function coverage(workspaceRoot, generation, deps) {
|
|
116
|
+
const enumerate = deps.enumerateRegisteredResources ?? defaultEnumerateRegisteredResources;
|
|
117
|
+
const readFile = deps.readFile ?? defaultReadFile;
|
|
118
|
+
try {
|
|
119
|
+
const resources = await enumerate(workspaceRoot);
|
|
120
|
+
if (!generation) return { covered: 0, total: resources.length };
|
|
121
|
+
const parsed = JSON.parse(await readFile(generation.file, 'utf8'));
|
|
122
|
+
const ids = new Set((parsed.descriptors || []).map((item) => item?.id));
|
|
123
|
+
const covered = resources.filter((resource) => {
|
|
124
|
+
const checksum = resource.from_checksum ?? resource.fromChecksum;
|
|
125
|
+
return (parsed.descriptors || []).some((item) => item?.from_checksum === checksum)
|
|
126
|
+
|| ids.has(descriptorIdFor(checksum, resource.mode));
|
|
127
|
+
}).length;
|
|
128
|
+
return { covered, total: resources.length };
|
|
129
|
+
} catch (error) { return { error: error?.message || String(error) }; }
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Read-only signing diagnostics. `--prune` is deliberately handled by the caller first. */
|
|
133
|
+
export async function signingStatusLines(input, deps = {}) {
|
|
134
|
+
const workspaceRoot = asWorkspace(input);
|
|
135
|
+
if (!workspaceRoot) throw new Error('workspaceRoot is required');
|
|
136
|
+
const status = await (deps.custodyStatus ?? custodyStatus)();
|
|
137
|
+
const current = await (deps.currentGeneration ?? defaultCurrentGeneration)(workspaceRoot);
|
|
138
|
+
let verdict = null;
|
|
139
|
+
if (current) {
|
|
140
|
+
try { verdict = await (deps.verifyGeneration ?? defaultVerifyGeneration)(workspaceRoot, current.file, deps.guardDescriptors ?? defaultGuardDescriptors); } catch (error) { verdict = { signature: `NOT VERIFIED: ${error.message}`, verified: false }; }
|
|
141
|
+
}
|
|
142
|
+
// Coverage probes every feature dir x mode through the stratum CLI (76 s and
|
|
143
|
+
// ~370 spawns on the compose repo itself, measured 2026-09-06) — opt-in.
|
|
144
|
+
const covered = deps.coverage ? await coverage(workspaceRoot, current, deps) : null;
|
|
145
|
+
const git = await runCommand(deps.execFile ?? defaultExecFile, 'git', ['status', '--porcelain', '--', '.compose/guard-upgrades'], { cwd: workspaceRoot, timeout: CUSTODY_TIMEOUT_MS });
|
|
146
|
+
const publicSigner = status.publicKeyLine ? (() => {
|
|
147
|
+
try { return parseAllowedSigners(`status ${status.publicKeyLine.trim()}\n`)[0]?.fingerprint ?? null; } catch { return null; }
|
|
148
|
+
})() : null;
|
|
149
|
+
const lines = [
|
|
150
|
+
'signing:',
|
|
151
|
+
` backend: ${status.backend}`,
|
|
152
|
+
` installed: ${status.installed ? 'yes' : 'no'}`,
|
|
153
|
+
` rule: ${status.rule}`,
|
|
154
|
+
` presence: ${status.presence}`,
|
|
155
|
+
` enrolled fingerprint: ${publicSigner || 'absent'}`,
|
|
156
|
+
` current generation: ${current ? current.sha : 'absent'}`,
|
|
157
|
+
` signature: ${current ? (verdict?.signature || 'NOT VERIFIED') : 'absent'}`,
|
|
158
|
+
` coverage: ${covered === null ? 'skipped (pass --coverage)' : covered.error ? `unknown (${covered.error})` : `${covered.covered}/${covered.total}`}`,
|
|
159
|
+
` generations committed: ${!git.error && !String(git.stdout).trim() ? 'yes' : 'no'}`,
|
|
160
|
+
` legacy flat pair: ${existsSync(path.join(workspaceRoot, LEGACY_FLAT)) && existsSync(path.join(workspaceRoot, `${LEGACY_FLAT}.sig`)) ? 'present — delete after committing generations' : 'absent'}`,
|
|
161
|
+
` cached admin credential: ${status.cachedCredential}`,
|
|
162
|
+
];
|
|
163
|
+
for (const detail of status.detail || []) lines.push(` detail: ${detail}`);
|
|
164
|
+
return makeResult('ok', lines, { custodyStatus: status, current, verdict, coverage: covered });
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export async function pruneGuardGenerations(input, deps = {}) {
|
|
168
|
+
const workspaceRoot = asWorkspace(input);
|
|
169
|
+
try {
|
|
170
|
+
const result = await withLock(workspaceRoot, deps, () => (deps.pruneGenerations ?? defaultPruneGenerations)(workspaceRoot));
|
|
171
|
+
return makeResult('ok', result.removed.length ? result.removed.map((sha) => `pruned: ${sha}`) : ['pruned: none'], result);
|
|
172
|
+
} catch (error) {
|
|
173
|
+
return makeResult('refused', [error?.message || String(error)], { code: 'upgrade_descriptor_unavailable' });
|
|
174
|
+
}
|
|
175
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { execFile as defaultExecFile } from 'node:child_process';
|
|
2
|
+
import { readFile, stat } from 'node:fs/promises';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
|
|
5
|
+
export const GUARD_DIR = '/Library/Compose/guard';
|
|
6
|
+
export const SIGNER_PATH = '/Library/Compose/guard/sign';
|
|
7
|
+
export const PUBKEY_PATH = '/Library/Compose/guard/signing-key.pub';
|
|
8
|
+
export const PRIVATE_DIR = '/Library/Compose/guard/private';
|
|
9
|
+
export const SUDOERS_PATH = '/private/etc/sudoers.d/compose-guard';
|
|
10
|
+
export const SUDO_LOCAL_PATH = '/private/etc/pam.d/sudo_local';
|
|
11
|
+
export const CUSTODY_TIMEOUT_MS = 120_000;
|
|
12
|
+
export const DESCRIPTOR_LOCK_TIMEOUT_MS = 150_000;
|
|
13
|
+
export const HINT_ENROL = 'compose guard enrol';
|
|
14
|
+
export const HINT_APPROVE = 'approve the Touch ID prompt; if none appeared, run compose guard sign from a terminal on this Mac';
|
|
15
|
+
|
|
16
|
+
/** The only environment the root-owned signer may inherit. */
|
|
17
|
+
export const SUDO_ENV = () => ({
|
|
18
|
+
PATH: '/usr/bin:/bin',
|
|
19
|
+
HOME: os.homedir(),
|
|
20
|
+
USER: os.userInfo().username,
|
|
21
|
+
LANG: 'C',
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
let testBackend = null;
|
|
25
|
+
|
|
26
|
+
/** The selected custody backend. Test injection wins over the host platform. */
|
|
27
|
+
export function custodyBackend() {
|
|
28
|
+
if (testBackend !== null) return typeof testBackend === 'string' ? testBackend : (testBackend.backend ?? 'test');
|
|
29
|
+
return process.platform === 'darwin' ? 'sudo' : 'none';
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Test-only backend injection. Kept unavailable to shipped callers. */
|
|
33
|
+
export function _testOnly_setCustodyBackend(backend) {
|
|
34
|
+
if (process.env.NODE_ENV !== 'test') {
|
|
35
|
+
throw new Error('_testOnly_setCustodyBackend is only available when NODE_ENV=test');
|
|
36
|
+
}
|
|
37
|
+
testBackend = backend ?? null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function firstLine(value, fallback) {
|
|
41
|
+
return String(value || '').split(/\r?\n/, 1)[0] || fallback;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function refusal(code, message, hint) {
|
|
45
|
+
return { ok: false, code, message, hint };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function isNotApproved(error, stderr) {
|
|
49
|
+
return error?.killed || error?.code === 'ETIMEDOUT'
|
|
50
|
+
|| /terminal is required|Sorry, try again|authentication failed|timed out/i.test(String(stderr || error?.message || ''));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function spawnWithInput(execFile, file, args, options, bytes) {
|
|
54
|
+
return new Promise((resolve) => {
|
|
55
|
+
let settled = false;
|
|
56
|
+
const done = (error, stdout, stderr) => {
|
|
57
|
+
if (settled) return;
|
|
58
|
+
settled = true;
|
|
59
|
+
resolve({ error, stdout: stdout || '', stderr: stderr || '' });
|
|
60
|
+
};
|
|
61
|
+
try {
|
|
62
|
+
const child = execFile(file, args, options, done);
|
|
63
|
+
if (child?.stdin) {
|
|
64
|
+
try { child.stdin.end(bytes); } catch { /* callback maps the failure */ }
|
|
65
|
+
}
|
|
66
|
+
child?.on?.('error', (error) => done(error, '', ''));
|
|
67
|
+
} catch (error) {
|
|
68
|
+
done(error, '', '');
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Ask the selected custody backend to sign exact in-memory bytes. */
|
|
74
|
+
export async function custodySign({ bytes, namespace }, deps = {}) {
|
|
75
|
+
if (testBackend && typeof testBackend === 'object' && typeof testBackend.sign === 'function') {
|
|
76
|
+
return testBackend.sign({ bytes, namespace });
|
|
77
|
+
}
|
|
78
|
+
if (custodyBackend() !== 'sudo') {
|
|
79
|
+
return refusal('upgrade_descriptor_unavailable', 'no signing custody on this platform', HINT_ENROL);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const { error, stdout, stderr } = await spawnWithInput(
|
|
83
|
+
deps.execFile ?? defaultExecFile,
|
|
84
|
+
'/usr/bin/sudo',
|
|
85
|
+
['-k', SIGNER_PATH, namespace],
|
|
86
|
+
{ env: SUDO_ENV(), timeout: CUSTODY_TIMEOUT_MS, maxBuffer: 1 << 20 },
|
|
87
|
+
bytes,
|
|
88
|
+
);
|
|
89
|
+
if (!error) return { ok: true, armored: stdout };
|
|
90
|
+
if (isNotApproved(error, stderr)) {
|
|
91
|
+
return refusal('signature_not_approved', firstLine(stderr || error.message, 'signature approval was not completed'), HINT_APPROVE);
|
|
92
|
+
}
|
|
93
|
+
return refusal('upgrade_descriptor_unavailable', firstLine(stderr || error.message, 'signer failed'), HINT_ENROL);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async function commandStatus(args, deps = {}) {
|
|
97
|
+
const { error, stdout, stderr } = await spawnWithInput(
|
|
98
|
+
deps.execFile ?? defaultExecFile, '/usr/bin/sudo', args, { env: SUDO_ENV(), timeout: 5_000 }, Buffer.alloc(0),
|
|
99
|
+
);
|
|
100
|
+
return { code: error?.code ?? 0, stdout, stderr, error };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async function expectedFile(file, mode) {
|
|
104
|
+
try {
|
|
105
|
+
const info = await stat(file);
|
|
106
|
+
return info.uid === 0 && (info.mode & 0o777) === mode;
|
|
107
|
+
} catch { return false; }
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Report separately what can be observed about the sudo installation. */
|
|
111
|
+
export async function custodyStatus(deps = {}) {
|
|
112
|
+
if (testBackend && typeof testBackend === 'object' && typeof testBackend.status === 'function') {
|
|
113
|
+
return testBackend.status();
|
|
114
|
+
}
|
|
115
|
+
const backend = custodyBackend();
|
|
116
|
+
if (backend !== 'sudo') {
|
|
117
|
+
return { backend, installed: false, rule: 'absent', presence: 'unknown', cachedCredential: 'unknown', publicKeyLine: null, detail: ['no signing custody on this platform'] };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const [guard, privateDir, signer, pubkey, sudoers, sudoLocal] = await Promise.all([
|
|
121
|
+
expectedFile(GUARD_DIR, 0o755), expectedFile(PRIVATE_DIR, 0o700), expectedFile(SIGNER_PATH, 0o755),
|
|
122
|
+
expectedFile(PUBKEY_PATH, 0o644), expectedFile(SUDOERS_PATH, 0o440), readFile(SUDO_LOCAL_PATH, 'utf8').catch(() => ''),
|
|
123
|
+
]);
|
|
124
|
+
const detail = [];
|
|
125
|
+
const listed = await commandStatus(['-n', '-l', SIGNER_PATH], deps);
|
|
126
|
+
const rule = listed.code === 0 ? 'present'
|
|
127
|
+
: /password is required/i.test(listed.stderr) ? 'unknown' : 'absent';
|
|
128
|
+
if (rule === 'unknown') detail.push('sudo requires authentication to list');
|
|
129
|
+
const cached = await commandStatus(['-n', '-v'], deps);
|
|
130
|
+
const cachedCredential = cached.code === 0 ? 'live' : cached.code === 1 ? 'none' : 'unknown';
|
|
131
|
+
const touchId = /^\s*auth\s+sufficient\s+pam_tid\.so/m.test(sudoLocal);
|
|
132
|
+
const remote = Boolean(process.env.SSH_CONNECTION || process.env.TMUX);
|
|
133
|
+
const presence = remote ? 'unlikely' : touchId ? 'likely' : 'unknown';
|
|
134
|
+
if (remote) detail.push('SSH_CONNECTION or TMUX prevents a reliable Touch ID prompt');
|
|
135
|
+
if (!touchId) detail.push('sudo_local has no active pam_tid line');
|
|
136
|
+
let publicKeyLine = null;
|
|
137
|
+
try { publicKeyLine = (await readFile(PUBKEY_PATH, 'utf8')).trim() || null; } catch { /* reported by installed */ }
|
|
138
|
+
return {
|
|
139
|
+
backend, installed: guard && privateDir && signer && pubkey && sudoers, rule, presence, cachedCredential, publicKeyLine, detail,
|
|
140
|
+
};
|
|
141
|
+
}
|