@skrr-ai/cli 0.1.49 → 0.1.50

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.
Files changed (101) hide show
  1. package/README.md +5 -4
  2. package/dist/base-command.js +16 -4
  3. package/dist/commands/agents/create.js +14 -7
  4. package/dist/commands/agents/update.js +11 -5
  5. package/dist/commands/code/index.d.ts +1 -0
  6. package/dist/commands/code/index.js +49 -5
  7. package/dist/commands/code/run.js +5 -1
  8. package/dist/commands/commitments/cycles.d.ts +19 -0
  9. package/dist/commands/commitments/cycles.js +46 -0
  10. package/dist/commands/commitments/effective-policy.js +26 -1
  11. package/dist/commands/commitments/explain.d.ts +17 -0
  12. package/dist/commands/commitments/explain.js +40 -0
  13. package/dist/commands/goals/create.d.ts +1 -0
  14. package/dist/commands/goals/create.js +22 -0
  15. package/dist/commands/logout.js +12 -1
  16. package/dist/commands/machines/dedicated/index.js +1 -1
  17. package/dist/commands/machines/dedicated/sign-in.js +4 -1
  18. package/dist/commands/machines/dedicated/terminal/kill.d.ts +21 -0
  19. package/dist/commands/machines/dedicated/terminal/kill.js +55 -0
  20. package/dist/commands/machines/dedicated/terminal/ls.d.ts +22 -0
  21. package/dist/commands/machines/dedicated/terminal/ls.js +70 -0
  22. package/dist/commands/machines/dedicated/terminal/rename.d.ts +23 -0
  23. package/dist/commands/machines/dedicated/terminal/rename.js +64 -0
  24. package/dist/commands/machines/dedicated/terminal.d.ts +9 -0
  25. package/dist/commands/machines/dedicated/terminal.js +27 -2
  26. package/dist/commands/machines/hosted/connect.js +2 -2
  27. package/dist/commands/machines/hosted/destroy.js +1 -1
  28. package/dist/commands/machines/hosted/exec.js +1 -1
  29. package/dist/commands/machines/hosted/list.js +1 -1
  30. package/dist/commands/machines/hosted/pause.js +1 -1
  31. package/dist/commands/machines/hosted/pull.js +1 -1
  32. package/dist/commands/machines/hosted/resume.js +1 -1
  33. package/dist/commands/machines/hosted/start.js +2 -2
  34. package/dist/commands/machines/hosted/status.js +2 -1
  35. package/dist/commands/tasks/create.js +1 -0
  36. package/dist/commands/tasks/list.js +1 -0
  37. package/dist/commands/tasks/update.js +1 -0
  38. package/dist/lib/agent-config.d.ts +2 -0
  39. package/dist/lib/agent-config.js +3 -1
  40. package/dist/lib/api-fetch.js +19 -0
  41. package/dist/lib/auth-storage.d.ts +14 -0
  42. package/dist/lib/auth-storage.js +14 -0
  43. package/dist/lib/commitments.d.ts +17 -2
  44. package/dist/lib/commitments.js +114 -0
  45. package/dist/lib/daemonBroker.d.ts +120 -31
  46. package/dist/lib/daemonBroker.js +313 -20
  47. package/dist/lib/dedicated-lease-command.d.ts +14 -0
  48. package/dist/lib/dedicated-lease-command.js +30 -1
  49. package/dist/lib/dedicated-machines.js +8 -25
  50. package/dist/lib/dedicated-service-command.d.ts +11 -3
  51. package/dist/lib/dedicated-service-command.js +20 -3
  52. package/dist/lib/dedicated-service.d.ts +46 -0
  53. package/dist/lib/dedicated-service.js +85 -7
  54. package/dist/lib/dedicated-terminal.d.ts +21 -0
  55. package/dist/lib/dedicated-terminal.js +104 -9
  56. package/dist/lib/first-party-harness-broker.d.ts +10 -0
  57. package/dist/lib/first-party-harness-broker.js +9 -0
  58. package/dist/lib/first-party-harness-doctor.js +41 -1
  59. package/dist/lib/first-party-harness-managed.d.ts +4 -3
  60. package/dist/lib/first-party-harness-project-trust.d.ts +125 -0
  61. package/dist/lib/first-party-harness-project-trust.js +364 -0
  62. package/dist/lib/first-party-harness.d.ts +9 -2
  63. package/dist/lib/first-party-harness.js +6 -5
  64. package/dist/lib/hosted-machines.d.ts +10 -1
  65. package/dist/lib/hosted-machines.js +36 -2
  66. package/dist/lib/login.js +16 -0
  67. package/dist/lib/machine-spend-cap.d.ts +23 -0
  68. package/dist/lib/machine-spend-cap.js +68 -0
  69. package/dist/lib/node-adapter.js +15 -3
  70. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/cliHandoffWire.d.ts +90 -0
  71. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/cliHandoffWire.js +113 -0
  72. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/credentialSession.d.ts +47 -0
  73. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/credentialSession.js +69 -0
  74. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/daemonToolApproval.d.ts +109 -0
  75. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/daemonToolApproval.js +171 -0
  76. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/messages.d.ts +8 -3
  77. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/messages.js +9 -4
  78. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/refresh.js +8 -9
  79. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/refreshClassification.d.ts +6 -0
  80. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/refreshClassification.js +32 -9
  81. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/sessionPermissionAuthority.d.ts +81 -0
  82. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/sessionPermissionAuthority.js +87 -0
  83. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/types.d.ts +6 -1
  84. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/cliHandoffWire.d.ts +90 -0
  85. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/cliHandoffWire.js +105 -0
  86. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/credentialSession.d.ts +47 -0
  87. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/credentialSession.js +66 -0
  88. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/daemonToolApproval.d.ts +109 -0
  89. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/daemonToolApproval.js +164 -0
  90. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/messages.d.ts +8 -3
  91. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/messages.js +9 -4
  92. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/refresh.js +8 -9
  93. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/refreshClassification.d.ts +6 -0
  94. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/refreshClassification.js +31 -9
  95. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/sessionPermissionAuthority.d.ts +81 -0
  96. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/sessionPermissionAuthority.js +80 -0
  97. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/types.d.ts +6 -1
  98. package/dist/node_modules/@skrr-ai/auth-core/package.json +40 -0
  99. package/dist/node_modules/@skrr-ai/data-provider/index.js +3413 -3343
  100. package/oclif.manifest.json +15769 -15342
  101. package/package.json +4 -1
@@ -0,0 +1,171 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.ATTESTED_TOOL_APPROVAL_TTL_MS = exports.DAEMON_TOOL_CATEGORIES = exports.DAEMON_TOOL_READ_ONLY_CAPABILITY = exports.DAEMON_TOOL_ATTESTED_APPROVAL_CAPABILITY = void 0;
4
+ exports.categoryForDaemonTool = categoryForDaemonTool;
5
+ exports.canonicalToolInputDigest = canonicalToolInputDigest;
6
+ exports.mintAttestedToolApproval = mintAttestedToolApproval;
7
+ exports.verifyAttestedToolApproval = verifyAttestedToolApproval;
8
+ const node_crypto_1 = require("node:crypto");
9
+ /**
10
+ * Server-attested tool approval (OSK-11975 follow-up to the `manual` mode
11
+ * refusal on `daemon:tool:request`).
12
+ *
13
+ * A daemon category in `manual` mode means "a PERSON approves each call". On
14
+ * the harness-session path the daemon asks through the permission bridge; on
15
+ * the daemon-tool path a cloud agent's call used to be refused outright,
16
+ * because the request carried no approval and there was no channel back to a
17
+ * person. The attested-approval protocol closes that gap without weakening
18
+ * the guarantee:
19
+ *
20
+ * 1. The daemon announces `daemon_tool_attested_approval_v1` and its
21
+ * resolved per-category posture (`toolPermissionModes`) at register time.
22
+ * 2. Before proxying a call, the server reads the announced posture for the
23
+ * tool's category. `manual` means it must ask first — through the same
24
+ * permission surface (`canUseTool` → `permission:request`) an in-process
25
+ * SDK turn would have used — and only then dispatch, carrying an
26
+ * `attestedApproval` object on the request.
27
+ * 3. The daemon verifies the attestation's SHAPE and BINDING — it is bound
28
+ * to this exact (toolUseId, toolName, toolInput digest) and expires
29
+ * quickly — and allows the call. A missing or malformed attestation gets
30
+ * the same named refusal as before.
31
+ *
32
+ * What the daemon verifies is the binding, not the server's honesty: the
33
+ * request already arrives over the daemon's authenticated server channel, so
34
+ * the field IS the attestation. What the binding buys is that the model —
35
+ * which authors `toolInput` — cannot mint an approval, and an approval for
36
+ * one call cannot be replayed onto another.
37
+ */
38
+ /** Announced by a daemon that honours `attestedApproval` on daemon:tool:request. */
39
+ exports.DAEMON_TOOL_ATTESTED_APPROVAL_CAPABILITY = 'daemon_tool_attested_approval_v1';
40
+ /**
41
+ * Announced by a daemon that honours `toolPosture: 'read_only'` on
42
+ * daemon:tool:request — the daemon-tool-path expression of the wire `plan`
43
+ * session mode: reads run, every mutating category is refused with a named
44
+ * reason. Carries INTENT like `browserPolicy: 'read_only'` — the daemon owns
45
+ * what read-only means, so the server cannot widen the fence by shipping a
46
+ * wider value.
47
+ */
48
+ exports.DAEMON_TOOL_READ_ONLY_CAPABILITY = 'daemon_tool_read_only_v1';
49
+ exports.DAEMON_TOOL_CATEGORIES = [
50
+ 'read',
51
+ 'write',
52
+ 'bash',
53
+ 'browser',
54
+ 'claude_session',
55
+ 'native_ui',
56
+ ];
57
+ /** Map a tool name to its category. Unknown tools take the restrictive answer. */
58
+ function categoryForDaemonTool(toolName) {
59
+ switch (String(toolName || '').toLowerCase()) {
60
+ case 'read':
61
+ case 'glob':
62
+ case 'grep':
63
+ case 'filetransferread':
64
+ return 'read';
65
+ case 'write':
66
+ case 'edit':
67
+ case 'multiedit':
68
+ case 'apply_patch':
69
+ case 'filetransferwrite':
70
+ return 'write';
71
+ // Reading or stopping a background command is part of running it.
72
+ case 'bash':
73
+ case 'taskoutput':
74
+ case 'taskstop':
75
+ return 'bash';
76
+ case 'browser':
77
+ return 'browser';
78
+ case 'native':
79
+ return 'native_ui';
80
+ case 'claude_session':
81
+ case 'claude-session':
82
+ return 'claude_session';
83
+ default:
84
+ return 'write';
85
+ }
86
+ }
87
+ // ---------------------------------------------------------------------------
88
+ // Attested approval
89
+ // ---------------------------------------------------------------------------
90
+ /**
91
+ * How long an attestation stays valid. Short: it answers ONE person, about
92
+ * THIS call, moments ago — it is not a standing grant. The server asks and
93
+ * dispatches back-to-back, so the window only has to outlive publish latency.
94
+ */
95
+ exports.ATTESTED_TOOL_APPROVAL_TTL_MS = 60 * 1000;
96
+ /**
97
+ * Deterministic serialization for the input digest: objects sort keys
98
+ * recursively, arrays keep order, non-JSON values normalize the way
99
+ * `JSON.stringify` would emit them (undefined/functions in objects drop, in
100
+ * arrays become null). Both runtimes must digest the SAME bytes — the server
101
+ * mints and the daemon verifies — so this lives here, once.
102
+ */
103
+ function canonicalJson(value) {
104
+ if (value === null || typeof value !== 'object') {
105
+ if (value === undefined || typeof value === 'function' || typeof value === 'symbol') {
106
+ return 'null';
107
+ }
108
+ return JSON.stringify(value);
109
+ }
110
+ if (Array.isArray(value)) {
111
+ return `[${value.map((entry) => canonicalJson(entry)).join(',')}]`;
112
+ }
113
+ const record = value;
114
+ const keys = Object.keys(record).filter((key) => record[key] !== undefined &&
115
+ typeof record[key] !== 'function' &&
116
+ typeof record[key] !== 'symbol');
117
+ keys.sort();
118
+ return `{${keys.map((key) => `${JSON.stringify(key)}:${canonicalJson(record[key])}`).join(',')}}`;
119
+ }
120
+ /** The digest both sides compute over `toolInput`. */
121
+ function canonicalToolInputDigest(toolInput) {
122
+ return (0, node_crypto_1.createHash)('sha256')
123
+ .update(canonicalJson(toolInput ?? {}))
124
+ .digest('hex');
125
+ }
126
+ /** Server side: mint the attestation after a person approved THIS call. */
127
+ function mintAttestedToolApproval(args) {
128
+ const now = args.now ?? Date.now();
129
+ return {
130
+ v: 1,
131
+ toolUseId: args.toolUseId,
132
+ toolName: args.toolName,
133
+ inputSha256: canonicalToolInputDigest(args.toolInput),
134
+ decidedBy: 'user',
135
+ issuedAt: now,
136
+ expiresAt: now + (args.ttlMs ?? exports.ATTESTED_TOOL_APPROVAL_TTL_MS),
137
+ };
138
+ }
139
+ /**
140
+ * Daemon side: verify an attestation against the call it claims to approve.
141
+ * Untrusted wire input — every field is checked, and any mismatch is a plain
142
+ * "no approval", never a partial credit. `reason` is for daemon logs; the
143
+ * refusal text stays generic so a probing caller learns nothing new.
144
+ */
145
+ function verifyAttestedToolApproval(approval, call) {
146
+ if (approval === undefined || approval === null)
147
+ return { ok: false, reason: 'absent' };
148
+ if (typeof approval !== 'object' || Array.isArray(approval)) {
149
+ return { ok: false, reason: 'malformed' };
150
+ }
151
+ const a = approval;
152
+ if (a.v !== 1 ||
153
+ typeof a.toolUseId !== 'string' ||
154
+ typeof a.toolName !== 'string' ||
155
+ typeof a.inputSha256 !== 'string' ||
156
+ a.decidedBy !== 'user' ||
157
+ typeof a.issuedAt !== 'number' ||
158
+ typeof a.expiresAt !== 'number') {
159
+ return { ok: false, reason: 'malformed' };
160
+ }
161
+ const now = call.now ?? Date.now();
162
+ if (a.expiresAt <= now || a.issuedAt > now + 30_000) {
163
+ return { ok: false, reason: 'expired' };
164
+ }
165
+ if (a.toolUseId !== call.toolUseId ||
166
+ a.toolName.toLowerCase() !== String(call.toolName).toLowerCase() ||
167
+ a.inputSha256 !== canonicalToolInputDigest(call.toolInput)) {
168
+ return { ok: false, reason: 'binding' };
169
+ }
170
+ return { ok: true };
171
+ }
@@ -1,4 +1,4 @@
1
- export type AuthFailureReason = 'REFRESH_INVALID' | 'REFRESH_REUSED' | 'REFRESH_EXPIRED' | 'SESSION_REVOKED' | 'TOKEN_EXPIRED' | 'TOKEN_INVALID' | 'UNKNOWN_401' | 'UNKNOWN';
1
+ export type AuthFailureReason = 'REFRESH_INVALID' | 'REFRESH_REUSED' | 'REFRESH_EXPIRED' | 'SESSION_REVOKED' | 'DEVICE_KEY_MISMATCH' | 'TOKEN_EXPIRED' | 'TOKEN_INVALID' | 'UNKNOWN_401' | 'UNKNOWN';
2
2
  /**
3
3
  * The command that recovers this process from a re-auth latch.
4
4
  *
@@ -11,8 +11,13 @@ export type AuthFailureReason = 'REFRESH_INVALID' | 'REFRESH_REUSED' | 'REFRESH_
11
11
  * something the other did not describe (OSK-10224).
12
12
  *
13
13
  * The binary is whatever the consumer registered with
14
- * `configureAuthCore({ binaryName })` — the binary the user actually typed —
15
- * and `--device` is added only when there is no TTY to open a browser from.
14
+ * `configureAuthCore({ binaryName })` — the binary the user actually typed.
15
+ *
16
+ * No flag. This used to append `--device` whenever stdout was not a TTY, which
17
+ * is always true for a daemon under launchd/systemd — and neither `skrrd login`
18
+ * nor `skrr login` has a `--device` option, so the one command the latch line
19
+ * named failed with `unknown option '--device'` (OSK-12056). Both logins already
20
+ * fall back to a paste-a-code arm when no browser can be opened.
16
21
  */
17
22
  export declare function reauthCommand(): string;
18
23
  export declare function formatReauthMessage(reason: AuthFailureReason | string): string;
@@ -17,6 +17,7 @@ const REASON_HUMAN = {
17
17
  REFRESH_REUSED: 'Your session was ended for security reasons (token replay detected).',
18
18
  REFRESH_EXPIRED: 'Your session has expired.',
19
19
  SESSION_REVOKED: 'Your session was ended (you signed out elsewhere or your password changed).',
20
+ DEVICE_KEY_MISMATCH: "This computer's device key no longer matches the one skrr registered for it, so its session cannot be renewed.",
20
21
  TOKEN_EXPIRED: 'Your access token has expired.',
21
22
  TOKEN_INVALID: 'Your saved credentials are no longer valid.',
22
23
  UNKNOWN_401: 'skrr needs you to sign in again.',
@@ -34,12 +35,16 @@ const REASON_HUMAN = {
34
35
  * something the other did not describe (OSK-10224).
35
36
  *
36
37
  * The binary is whatever the consumer registered with
37
- * `configureAuthCore({ binaryName })` — the binary the user actually typed —
38
- * and `--device` is added only when there is no TTY to open a browser from.
38
+ * `configureAuthCore({ binaryName })` — the binary the user actually typed.
39
+ *
40
+ * No flag. This used to append `--device` whenever stdout was not a TTY, which
41
+ * is always true for a daemon under launchd/systemd — and neither `skrrd login`
42
+ * nor `skrr login` has a `--device` option, so the one command the latch line
43
+ * named failed with `unknown option '--device'` (OSK-12056). Both logins already
44
+ * fall back to a paste-a-code arm when no browser can be opened.
39
45
  */
40
46
  function reauthCommand() {
41
- const bin = (0, runtime_js_1.getAuthBinaryName)();
42
- return process.stdout.isTTY ? `${bin} login` : `${bin} login --device`;
47
+ return `${(0, runtime_js_1.getAuthBinaryName)()} login`;
43
48
  }
44
49
  function formatReauthMessage(reason) {
45
50
  const norm = REASON_HUMAN[reason] || REASON_HUMAN.UNKNOWN;
@@ -41,6 +41,7 @@ const node_fs_1 = __importDefault(require("node:fs"));
41
41
  const node_path_1 = __importDefault(require("node:path"));
42
42
  const proper_lockfile_1 = __importDefault(require("proper-lockfile"));
43
43
  const runtime_js_1 = require("./runtime.js");
44
+ const refreshClassification_js_1 = require("./refreshClassification.js");
44
45
  const types_js_1 = require("./types.js");
45
46
  /**
46
47
  * P1-6: Reduced from 30 s to 5 s.
@@ -820,14 +821,12 @@ async function doRefresh(serverUrl, currentRefreshToken, daemonId, options = {})
820
821
  catch {
821
822
  /* ignore */
822
823
  }
823
- const code = parsedBody.code;
824
- const permanent = [
825
- 'REFRESH_INVALID',
826
- 'REFRESH_REUSED',
827
- 'REFRESH_EXPIRED',
828
- 'SESSION_REVOKED',
829
- ];
830
- if (code && permanent.includes(code)) {
824
+ // The server's code, mapped through the one table the stateless classifier
825
+ // reads too — so a permanent refusal that is not spelled as a reason name
826
+ // (DEVICE_PROOF_THUMBPRINT_MISMATCH) latches instead of riding the
827
+ // unknown-401 budget forever (OSK-12056).
828
+ const code = (0, refreshClassification_js_1.permanentReasonForRefreshCode)(parsedBody.code);
829
+ if (code) {
831
830
  // A KNOWN credential-death code is genuinely permanent — latch
832
831
  // immediately, exactly as before. It is NOT routed through the
833
832
  // unknown-401 budget.
@@ -857,7 +856,7 @@ async function doRefresh(serverUrl, currentRefreshToken, daemonId, options = {})
857
856
  }
858
857
  // Unknown-shape 401 — unknown code, missing code, or unparseable body.
859
858
  // By construction this is NOT a daemon's actual refresh-family burn
860
- // (those arrive as one of the four KNOWN codes above); it most likely
859
+ // (those arrive as one of the KNOWN codes above); it most likely
861
860
  // came from infra OverSky does not fully control — a proxy/middleware
862
861
  // regression, a clock-skew replay rejection (DEVICE_PROOF_REPLAYED),
863
862
  // or a half-rolled-out server route.
@@ -36,6 +36,12 @@ export interface ClassifiedRefreshFailure {
36
36
  failure: PermanentAuthFailure | TransientAuthFailure;
37
37
  }
38
38
  export type ClassifiedRefreshResult = ClassifiedRefreshSuccess | ClassifiedRefreshFailure;
39
+ /**
40
+ * The permanent reason a 401 refresh refusal's `code` names, or `null` when the
41
+ * code is missing or unrecognised (an unknown-shape 401). The ONE mapping both
42
+ * this classifier and the daemon's `refresh.ts` read.
43
+ */
44
+ export declare function permanentReasonForRefreshCode(code: string | undefined | null): PermanentAuthReason | null;
39
45
  /**
40
46
  * Classify an HTTP response from /api/auth/refresh (or /api/daemons/token/refresh)
41
47
  * into a discriminated union of success or failure.
@@ -16,6 +16,7 @@
16
16
  */
17
17
  Object.defineProperty(exports, "__esModule", { value: true });
18
18
  exports.TransientAuthFailure = exports.PermanentAuthFailure = void 0;
19
+ exports.permanentReasonForRefreshCode = permanentReasonForRefreshCode;
19
20
  exports.classifyRefreshResponse = classifyRefreshResponse;
20
21
  const types_js_1 = require("./types.js");
21
22
  Object.defineProperty(exports, "PermanentAuthFailure", { enumerable: true, get: function () { return types_js_1.PermanentAuthFailure; } });
@@ -32,6 +33,33 @@ const PERMANENT_CODE_SET = new Set([
32
33
  'REFRESH_EXPIRED',
33
34
  'SESSION_REVOKED',
34
35
  ]);
36
+ /**
37
+ * Server refusal codes that are permanent but are not themselves reason names.
38
+ *
39
+ * `DEVICE_PROOF_THUMBPRINT_MISMATCH` (`daemonRefreshTokens.verifyDeviceProofPinForRow`)
40
+ * means the proof was signed by a key other than the one enrolled on the daemon's
41
+ * row. Every retry is signed by the same key, so it can never succeed — but
42
+ * because it was not in the set above it fell through to the unknown-401 path,
43
+ * where a daemon retrying on a five-minute cadence never exhausted its budget:
44
+ * one laptop logged 741 "transient, retrying" refusals over four days, and the
45
+ * reason it eventually surfaced was `UNKNOWN_401` (OSK-12056).
46
+ */
47
+ const PERMANENT_CODE_ALIASES = {
48
+ REFRESH_MISSING: 'REFRESH_INVALID',
49
+ DEVICE_PROOF_THUMBPRINT_MISMATCH: 'DEVICE_KEY_MISMATCH',
50
+ };
51
+ /**
52
+ * The permanent reason a 401 refresh refusal's `code` names, or `null` when the
53
+ * code is missing or unrecognised (an unknown-shape 401). The ONE mapping both
54
+ * this classifier and the daemon's `refresh.ts` read.
55
+ */
56
+ function permanentReasonForRefreshCode(code) {
57
+ if (!code)
58
+ return null;
59
+ if (PERMANENT_CODE_SET.has(code))
60
+ return code;
61
+ return PERMANENT_CODE_ALIASES[code] ?? null;
62
+ }
35
63
  /**
36
64
  * Classify an HTTP response from /api/auth/refresh (or /api/daemons/token/refresh)
37
65
  * into a discriminated union of success or failure.
@@ -64,17 +92,12 @@ function classifyRefreshResponse(status, body) {
64
92
  }
65
93
  // 401 — auth failure; check for known permanent code
66
94
  if (status === 401) {
67
- if (body?.code === 'REFRESH_MISSING') {
68
- return {
69
- kind: 'failure',
70
- failure: new types_js_1.PermanentAuthFailure('REFRESH_INVALID', body.message ?? 'Refresh token missing'),
71
- };
72
- }
73
- const code = body?.code;
74
- if (code && PERMANENT_CODE_SET.has(code)) {
95
+ const reason = permanentReasonForRefreshCode(body?.code);
96
+ if (reason) {
97
+ const fallback = body?.code === 'REFRESH_MISSING' ? 'Refresh token missing' : reason;
75
98
  return {
76
99
  kind: 'failure',
77
- failure: new types_js_1.PermanentAuthFailure(code, body?.message ?? code),
100
+ failure: new types_js_1.PermanentAuthFailure(reason, body?.message ?? fallback),
78
101
  };
79
102
  }
80
103
  // Unknown-shape 401 (no code, or a code we don't recognise). This is NOT a
@@ -0,0 +1,81 @@
1
+ /**
2
+ * Session permission authority — the ONE rule that decides whether a session's
3
+ * execution contract governs the run's permissions, shared by the server and
4
+ * the daemon (OSK-11975 introduced it; it was implemented twice — the server
5
+ * read `kind !== 'commitment'`, the daemon read the `permissionSource` marker —
6
+ * which is exactly how a new contract kind or a new source value would drift
7
+ * them apart).
8
+ *
9
+ * The wire model:
10
+ *
11
+ * - The contract is closed (`hasOnlyKeys` on the daemon), so "whose
12
+ * permissions govern" can never ride INSIDE it. It is a top-level session
13
+ * field: `permissionSource`.
14
+ * - `'agent'` means the Agent's own configured permission mode, tool policy,
15
+ * Grants, session env, MCP servers and machine consent govern the run,
16
+ * exactly as for an unattended Task of the same Agent. Today only a
17
+ * `commitment` contract may carry it.
18
+ * - A `commitment` contract WITHOUT the marker (an older server mid-deploy)
19
+ * still governs permissions — the marker is the version signal, so its
20
+ * absence must keep the legacy fence, not silently lift it.
21
+ * - `task_delivery` contracts always govern, marker or not.
22
+ *
23
+ * Server vs daemon asymmetry, on purpose: the server STAMPS the marker
24
+ * (`permissionSourceForContract`), so on its own side the predicate reduces to
25
+ * "a commitment never governs" — `contractGovernsPermissions(c,
26
+ * permissionSourceForContract(c))` is exactly `kind !== 'commitment'`. The
27
+ * daemon reads the marker it received, because it cannot distinguish "new
28
+ * server, agent permissions" from "old server, contract fence" any other way.
29
+ * Both call the same function; only the input differs.
30
+ */
31
+ /** The wire vocabulary for `permissionSource`. Top-level session field. */
32
+ export declare const SESSION_PERMISSION_SOURCES: readonly ["agent"];
33
+ export type SessionPermissionSource = (typeof SESSION_PERMISSION_SOURCES)[number];
34
+ /**
35
+ * The daemon capability that proves a build honours `permissionSource`. The
36
+ * server announces it in preflight and refuses to dispatch a Commitment Run to
37
+ * a daemon without it (`COMMITMENT_AGENT_PERMISSIONS_RUNTIME_UNSUPPORTED`), so
38
+ * the marker never reaches a daemon that would ignore it.
39
+ */
40
+ export declare const COMMITMENT_AGENT_PERMISSIONS_CAPABILITY = "commitment_execution_contract_v2_agent_permissions";
41
+ /**
42
+ * The minimal contract shape this rule reads. The predicate only ever needs
43
+ * `kind` — keeping the input structural means this module never imports the
44
+ * full `CommitmentExecutionContract` type, which lives daemon-side
45
+ * (`daemon/src/commitment-execution-contract.ts`) and server-side in the
46
+ * commitment services.
47
+ */
48
+ export type PermissionContractKind = {
49
+ kind?: unknown;
50
+ } | null | undefined;
51
+ /**
52
+ * Untrusted wire input: anything but an exact vocabulary literal reads as
53
+ * absent. Never widen by coercion — an unrecognized future value must degrade
54
+ * to "contract governs", the conservative answer.
55
+ */
56
+ export declare function normalizeSessionPermissionSource(value: unknown): SessionPermissionSource | undefined;
57
+ /**
58
+ * What the server stamps on the wire for this contract. A `commitment`
59
+ * contract no longer carries permissions (OSK-11975); every other contract
60
+ * kind keeps its own authority and stamps nothing.
61
+ */
62
+ export declare function permissionSourceForContract(contract: PermissionContractKind): SessionPermissionSource | undefined;
63
+ /**
64
+ * Whether the contract is the run's PERMISSION authority — the single place
65
+ * this rule lives.
66
+ *
67
+ * True for a non-commitment contract regardless of marker, and for a
68
+ * commitment whose server did not declare agent permissions. False for no
69
+ * contract at all, and for a commitment carrying `permissionSource: 'agent'`,
70
+ * which then follows the Agent's own permissions exactly as a Task does while
71
+ * the contract keeps its non-permission roles (normalized shape, ephemeral
72
+ * worktree, result validation, `GIT_OPTIONAL_LOCKS`, provider restriction).
73
+ */
74
+ export declare function contractGovernsPermissions(contract: PermissionContractKind, permissionSource: unknown): boolean;
75
+ /**
76
+ * The contract when it governs permissions, else `undefined` — for the sites
77
+ * that used `executionContract` as "is this run permission-fenced".
78
+ */
79
+ export declare function permissionGoverningContract<T extends {
80
+ kind?: unknown;
81
+ }>(contract: T | null | undefined, permissionSource: unknown): T | undefined;
@@ -0,0 +1,87 @@
1
+ "use strict";
2
+ /**
3
+ * Session permission authority — the ONE rule that decides whether a session's
4
+ * execution contract governs the run's permissions, shared by the server and
5
+ * the daemon (OSK-11975 introduced it; it was implemented twice — the server
6
+ * read `kind !== 'commitment'`, the daemon read the `permissionSource` marker —
7
+ * which is exactly how a new contract kind or a new source value would drift
8
+ * them apart).
9
+ *
10
+ * The wire model:
11
+ *
12
+ * - The contract is closed (`hasOnlyKeys` on the daemon), so "whose
13
+ * permissions govern" can never ride INSIDE it. It is a top-level session
14
+ * field: `permissionSource`.
15
+ * - `'agent'` means the Agent's own configured permission mode, tool policy,
16
+ * Grants, session env, MCP servers and machine consent govern the run,
17
+ * exactly as for an unattended Task of the same Agent. Today only a
18
+ * `commitment` contract may carry it.
19
+ * - A `commitment` contract WITHOUT the marker (an older server mid-deploy)
20
+ * still governs permissions — the marker is the version signal, so its
21
+ * absence must keep the legacy fence, not silently lift it.
22
+ * - `task_delivery` contracts always govern, marker or not.
23
+ *
24
+ * Server vs daemon asymmetry, on purpose: the server STAMPS the marker
25
+ * (`permissionSourceForContract`), so on its own side the predicate reduces to
26
+ * "a commitment never governs" — `contractGovernsPermissions(c,
27
+ * permissionSourceForContract(c))` is exactly `kind !== 'commitment'`. The
28
+ * daemon reads the marker it received, because it cannot distinguish "new
29
+ * server, agent permissions" from "old server, contract fence" any other way.
30
+ * Both call the same function; only the input differs.
31
+ */
32
+ Object.defineProperty(exports, "__esModule", { value: true });
33
+ exports.COMMITMENT_AGENT_PERMISSIONS_CAPABILITY = exports.SESSION_PERMISSION_SOURCES = void 0;
34
+ exports.normalizeSessionPermissionSource = normalizeSessionPermissionSource;
35
+ exports.permissionSourceForContract = permissionSourceForContract;
36
+ exports.contractGovernsPermissions = contractGovernsPermissions;
37
+ exports.permissionGoverningContract = permissionGoverningContract;
38
+ /** The wire vocabulary for `permissionSource`. Top-level session field. */
39
+ exports.SESSION_PERMISSION_SOURCES = ['agent'];
40
+ /**
41
+ * The daemon capability that proves a build honours `permissionSource`. The
42
+ * server announces it in preflight and refuses to dispatch a Commitment Run to
43
+ * a daemon without it (`COMMITMENT_AGENT_PERMISSIONS_RUNTIME_UNSUPPORTED`), so
44
+ * the marker never reaches a daemon that would ignore it.
45
+ */
46
+ exports.COMMITMENT_AGENT_PERMISSIONS_CAPABILITY = 'commitment_execution_contract_v2_agent_permissions';
47
+ /**
48
+ * Untrusted wire input: anything but an exact vocabulary literal reads as
49
+ * absent. Never widen by coercion — an unrecognized future value must degrade
50
+ * to "contract governs", the conservative answer.
51
+ */
52
+ function normalizeSessionPermissionSource(value) {
53
+ return exports.SESSION_PERMISSION_SOURCES.includes(value)
54
+ ? value
55
+ : undefined;
56
+ }
57
+ /**
58
+ * What the server stamps on the wire for this contract. A `commitment`
59
+ * contract no longer carries permissions (OSK-11975); every other contract
60
+ * kind keeps its own authority and stamps nothing.
61
+ */
62
+ function permissionSourceForContract(contract) {
63
+ return contract?.kind === 'commitment' ? 'agent' : undefined;
64
+ }
65
+ /**
66
+ * Whether the contract is the run's PERMISSION authority — the single place
67
+ * this rule lives.
68
+ *
69
+ * True for a non-commitment contract regardless of marker, and for a
70
+ * commitment whose server did not declare agent permissions. False for no
71
+ * contract at all, and for a commitment carrying `permissionSource: 'agent'`,
72
+ * which then follows the Agent's own permissions exactly as a Task does while
73
+ * the contract keeps its non-permission roles (normalized shape, ephemeral
74
+ * worktree, result validation, `GIT_OPTIONAL_LOCKS`, provider restriction).
75
+ */
76
+ function contractGovernsPermissions(contract, permissionSource) {
77
+ if (!contract)
78
+ return false;
79
+ return !(contract.kind === 'commitment' && normalizeSessionPermissionSource(permissionSource) === 'agent');
80
+ }
81
+ /**
82
+ * The contract when it governs permissions, else `undefined` — for the sites
83
+ * that used `executionContract` as "is this run permission-fenced".
84
+ */
85
+ function permissionGoverningContract(contract, permissionSource) {
86
+ return contract && contractGovernsPermissions(contract, permissionSource) ? contract : undefined;
87
+ }
@@ -62,6 +62,11 @@ export interface TokenPair {
62
62
  * - TOKEN_* — access-token specific (server pushes 4402 close;
63
63
  * daemon classifies as TOKEN_INVALID/TOKEN_EXPIRED).
64
64
  * - SESSION_REVOKED — admin/forensic revoke; whole family burned.
65
+ * - DEVICE_KEY_MISMATCH — the refresh carried a device proof signed by a key
66
+ * other than the one enrolled on this daemon's row
67
+ * (server code `DEVICE_PROOF_THUMBPRINT_MISMATCH`).
68
+ * The daemon signs every retry with the same key, so
69
+ * retrying can never succeed.
65
70
  * - UNKNOWN_401 — server returned 401 with a code we don't
66
71
  * recognize. Treated as permanent (better to prompt
67
72
  * re-auth than to loop forever) but tagged
@@ -74,7 +79,7 @@ export interface TokenPair {
74
79
  * Without this, the client `describeAuthFailure` exhaustiveness check
75
80
  * was silently broken for the non-REFRESH branches.
76
81
  */
77
- export type PermanentAuthReason = 'REFRESH_INVALID' | 'REFRESH_REUSED' | 'REFRESH_EXPIRED' | 'SESSION_REVOKED' | 'TOKEN_INVALID' | 'TOKEN_EXPIRED' | 'UNKNOWN_401';
82
+ export type PermanentAuthReason = 'REFRESH_INVALID' | 'REFRESH_REUSED' | 'REFRESH_EXPIRED' | 'SESSION_REVOKED' | 'DEVICE_KEY_MISMATCH' | 'TOKEN_INVALID' | 'TOKEN_EXPIRED' | 'UNKNOWN_401';
78
83
  export declare class PermanentAuthFailure extends Error {
79
84
  readonly code: PermanentAuthReason;
80
85
  constructor(code: PermanentAuthReason, message: string);
@@ -0,0 +1,90 @@
1
+ /**
2
+ * cliHandoffWire.ts — the ONE declaration of the CLI↔daemon hand-off wire
3
+ * contracts, shared by the daemon (writer/server) and the CLI (reader).
4
+ *
5
+ * Two contracts live here:
6
+ *
7
+ * 1. The hand-off descriptor file (`descriptor.json` on Dedicated guests,
8
+ * `daemon-island.<profile>.json` on laptops). Until now the shape was
9
+ * declared twice — `DedicatedCliHandoffDescriptor` daemon-side and
10
+ * `LocalBootstrap` CLI-side — two type declarations for one file format,
11
+ * the same drift disease `sessionPermissionAuthority` fixed.
12
+ * 2. The broker-mode redeem exchange: `POST /v1/auth/cli-handoff-token`
13
+ * request/response plus its error codes.
14
+ *
15
+ * Readers stay loose (unknown fields ignored, absent `handoffModes` reads as
16
+ * refresh-family-only) so a new field never breaks an older peer; writers
17
+ * must produce the full shape.
18
+ */
19
+ /**
20
+ * What a caller may redeem the descriptor secret for.
21
+ * - `refresh_family`: the legacy durable mint (90-day cli-scope family).
22
+ * - `access_token`: broker mode — a short-lived access token from a
23
+ * daemon-owned family; nothing durable leaves the daemon.
24
+ */
25
+ export declare const CLI_HANDOFF_MODES: readonly ["refresh_family", "access_token"];
26
+ export type CliHandoffMode = (typeof CLI_HANDOFF_MODES)[number];
27
+ /** Broker-mode redeem route on the daemon's loopback server. */
28
+ export declare const CLI_HANDOFF_TOKEN_PATH = "/v1/auth/cli-handoff-token";
29
+ /** Error codes this contract emits. String literals so both ends compare
30
+ * without importing a symbol they may not have. */
31
+ export declare const CLI_HANDOFF_TOKEN_ERROR_UNAVAILABLE = "HANDOFF_TOKEN_UNAVAILABLE";
32
+ /** Emitted by the LEGACY /v1/auth/cli-handoff route when a hand-off-secret
33
+ * caller asks for a refresh family the daemon no longer mints for it. */
34
+ export declare const CLI_HANDOFF_ERROR_BROKER_ONLY = "HANDOFF_BROKER_ONLY";
35
+ /**
36
+ * The descriptor file format. `version` is absent on the oldest island files
37
+ * and `1` on the first dedicated-guest descriptors; `handoffModes` first
38
+ * appears at version 2. Every field the CLI needs to dial is validated; the
39
+ * rest ride along untyped.
40
+ */
41
+ export interface CliHandoffDescriptor {
42
+ version?: number;
43
+ pid?: number;
44
+ daemonId?: string;
45
+ host?: string;
46
+ port?: number;
47
+ /** Base64 handshake secret — hand-off-only on guests, island on laptops. */
48
+ secret?: string;
49
+ /** Server URL the daemon is authenticated against. Older daemons omit it;
50
+ * readers refuse to broker without it (never mint against an unknown
51
+ * server). */
52
+ serverUrl?: string;
53
+ /** Daemon-authored epoch counter; bumped when the descriptor is republished. */
54
+ epoch?: number;
55
+ handoffModes?: CliHandoffMode[];
56
+ }
57
+ /**
58
+ * Parse an untrusted descriptor JSON value. Returns null when the fields the
59
+ * caller cannot proceed without — host, port, secret — are absent or
60
+ * mistyped. Unknown fields and future `version` values are tolerated: the
61
+ * reader ignores them, which is what makes additive evolution safe.
62
+ */
63
+ export declare function parseCliHandoffDescriptor(value: unknown): CliHandoffDescriptor | null;
64
+ /**
65
+ * Untrusted `handoffModes` input → known literals only, deduped. An
66
+ * unrecognized future mode is dropped rather than widening by coercion.
67
+ */
68
+ export declare function normalizeHandoffModes(value: unknown): CliHandoffMode[];
69
+ /**
70
+ * The versioned-default rule: a descriptor that never declares modes offers
71
+ * only the durable mint it has always offered. Mode negotiation is purely
72
+ * additive — a daemon advertises the new mode, it never un-advertises the
73
+ * old one by accident.
74
+ */
75
+ export declare function advertisedHandoffModes(d: CliHandoffDescriptor): CliHandoffMode[];
76
+ export declare function descriptorOffersAccessToken(d: CliHandoffDescriptor): boolean;
77
+ export interface CliHandoffTokenRequest {
78
+ /** Bypass the daemon broker's cached token (the 401-recovery path). */
79
+ forceRefresh?: boolean;
80
+ }
81
+ export interface CliHandoffTokenResponse {
82
+ accessToken: string;
83
+ /** Absolute expiry in ms since epoch, when the daemon knows it. */
84
+ accessExpiresAt?: number;
85
+ /** Server the minted credential belongs to — lets the caller verify it is
86
+ * about to use a token for the API it thinks it is talking to. */
87
+ serverUrl?: string;
88
+ }
89
+ /** Parse the redeem response; null when the token itself is absent. */
90
+ export declare function parseCliHandoffTokenResponse(value: unknown): CliHandoffTokenResponse | null;