@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,105 @@
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 const CLI_HANDOFF_MODES = ['refresh_family', 'access_token'];
26
+ /** Broker-mode redeem route on the daemon's loopback server. */
27
+ export const CLI_HANDOFF_TOKEN_PATH = '/v1/auth/cli-handoff-token';
28
+ /** Error codes this contract emits. String literals so both ends compare
29
+ * without importing a symbol they may not have. */
30
+ export const CLI_HANDOFF_TOKEN_ERROR_UNAVAILABLE = 'HANDOFF_TOKEN_UNAVAILABLE';
31
+ /** Emitted by the LEGACY /v1/auth/cli-handoff route when a hand-off-secret
32
+ * caller asks for a refresh family the daemon no longer mints for it. */
33
+ export const CLI_HANDOFF_ERROR_BROKER_ONLY = 'HANDOFF_BROKER_ONLY';
34
+ /**
35
+ * Parse an untrusted descriptor JSON value. Returns null when the fields the
36
+ * caller cannot proceed without — host, port, secret — are absent or
37
+ * mistyped. Unknown fields and future `version` values are tolerated: the
38
+ * reader ignores them, which is what makes additive evolution safe.
39
+ */
40
+ export function parseCliHandoffDescriptor(value) {
41
+ if (value == null || typeof value !== 'object')
42
+ return null;
43
+ const v = value;
44
+ if (typeof v.host !== 'string' || v.host.length === 0)
45
+ return null;
46
+ if (typeof v.port !== 'number' || !Number.isInteger(v.port) || v.port <= 0)
47
+ return null;
48
+ if (typeof v.secret !== 'string' || v.secret.length === 0)
49
+ return null;
50
+ return {
51
+ ...(typeof v.version === 'number' ? { version: v.version } : {}),
52
+ ...(typeof v.pid === 'number' ? { pid: v.pid } : {}),
53
+ ...(typeof v.daemonId === 'string' ? { daemonId: v.daemonId } : {}),
54
+ host: v.host,
55
+ port: v.port,
56
+ secret: v.secret,
57
+ ...(typeof v.serverUrl === 'string' ? { serverUrl: v.serverUrl } : {}),
58
+ ...(typeof v.epoch === 'number' ? { epoch: v.epoch } : {}),
59
+ ...(Array.isArray(v.handoffModes)
60
+ ? { handoffModes: normalizeHandoffModes(v.handoffModes) }
61
+ : {}),
62
+ };
63
+ }
64
+ /**
65
+ * Untrusted `handoffModes` input → known literals only, deduped. An
66
+ * unrecognized future mode is dropped rather than widening by coercion.
67
+ */
68
+ export function normalizeHandoffModes(value) {
69
+ if (!Array.isArray(value))
70
+ return [];
71
+ const out = [];
72
+ for (const m of value) {
73
+ if (CLI_HANDOFF_MODES.includes(m) &&
74
+ !out.includes(m)) {
75
+ out.push(m);
76
+ }
77
+ }
78
+ return out;
79
+ }
80
+ /**
81
+ * The versioned-default rule: a descriptor that never declares modes offers
82
+ * only the durable mint it has always offered. Mode negotiation is purely
83
+ * additive — a daemon advertises the new mode, it never un-advertises the
84
+ * old one by accident.
85
+ */
86
+ export function advertisedHandoffModes(d) {
87
+ const declared = normalizeHandoffModes(d.handoffModes);
88
+ return declared.length > 0 ? declared : ['refresh_family'];
89
+ }
90
+ export function descriptorOffersAccessToken(d) {
91
+ return advertisedHandoffModes(d).includes('access_token');
92
+ }
93
+ /** Parse the redeem response; null when the token itself is absent. */
94
+ export function parseCliHandoffTokenResponse(value) {
95
+ if (value == null || typeof value !== 'object')
96
+ return null;
97
+ const v = value;
98
+ if (typeof v.accessToken !== 'string' || v.accessToken.length === 0)
99
+ return null;
100
+ return {
101
+ accessToken: v.accessToken,
102
+ ...(typeof v.accessExpiresAt === 'number' ? { accessExpiresAt: v.accessExpiresAt } : {}),
103
+ ...(typeof v.serverUrl === 'string' ? { serverUrl: v.serverUrl } : {}),
104
+ };
105
+ }
@@ -0,0 +1,47 @@
1
+ /**
2
+ * credentialSession.ts — the shared credential-lifecycle kernel (policy half).
3
+ *
4
+ * A credential lifecycle is policy × mechanism. The policy — serve a cached
5
+ * token until its expiry-skew window, collapse concurrent acquires onto one
6
+ * in-flight operation, invalidate on a resource-side 401 — is identical for
7
+ * every surface and was being re-implemented per consumer (task token-broker,
8
+ * delegated-cli redemption, the human CLI refresh path). The mechanism —
9
+ * WHERE the durable secret lives and HOW a token is acquired — legitimately
10
+ * differs (in-memory daemon family vs file+envelope+lock vs loopback redeem)
11
+ * and stays behind the `acquire` seam.
12
+ *
13
+ * Deliberately thin: no persistence, no retry policy, no logging. Callers
14
+ * own their store and their error surfaces; this owns the cache/single-
15
+ * flight/invalidate invariants so they cannot drift between surfaces.
16
+ */
17
+ /** What an acquire returns. `expiresAtMs` absent means "no known expiry" —
18
+ * the token is served until `invalidate()` or an acquire replaces it. */
19
+ export interface AcquiredCredential {
20
+ accessToken: string;
21
+ /** Absolute expiry in ms since epoch, when the issuer tells us. */
22
+ expiresAtMs?: number;
23
+ }
24
+ export interface CredentialSessionOptions {
25
+ /**
26
+ * The mechanism. Called with `forceRefresh: true` when the caller asked to
27
+ * bypass the cache (the 401-recovery path) — implementations should renew
28
+ * rather than serve their own cache. A rejected promise propagates to every
29
+ * waiter and is never cached.
30
+ */
31
+ acquire: (forceRefresh: boolean) => Promise<AcquiredCredential>;
32
+ /** Serve the cached token until this many ms before expiry. Default 30s. */
33
+ skewMs?: number;
34
+ /** Injectable clock for tests. */
35
+ now?: () => number;
36
+ }
37
+ export interface CredentialSession {
38
+ /**
39
+ * Resolves a usable access token: the cached one while it is inside its
40
+ * freshness window, otherwise a single shared acquire. `forceRefresh`
41
+ * discards the cache first (use after a resource 401).
42
+ */
43
+ getToken(forceRefresh?: boolean): Promise<string>;
44
+ /** Drop the cached token; the next getToken() acquires. */
45
+ invalidate(): void;
46
+ }
47
+ export declare function createCredentialSession(opts: CredentialSessionOptions): CredentialSession;
@@ -0,0 +1,66 @@
1
+ /**
2
+ * credentialSession.ts — the shared credential-lifecycle kernel (policy half).
3
+ *
4
+ * A credential lifecycle is policy × mechanism. The policy — serve a cached
5
+ * token until its expiry-skew window, collapse concurrent acquires onto one
6
+ * in-flight operation, invalidate on a resource-side 401 — is identical for
7
+ * every surface and was being re-implemented per consumer (task token-broker,
8
+ * delegated-cli redemption, the human CLI refresh path). The mechanism —
9
+ * WHERE the durable secret lives and HOW a token is acquired — legitimately
10
+ * differs (in-memory daemon family vs file+envelope+lock vs loopback redeem)
11
+ * and stays behind the `acquire` seam.
12
+ *
13
+ * Deliberately thin: no persistence, no retry policy, no logging. Callers
14
+ * own their store and their error surfaces; this owns the cache/single-
15
+ * flight/invalidate invariants so they cannot drift between surfaces.
16
+ */
17
+ const DEFAULT_SKEW_MS = 30_000;
18
+ export function createCredentialSession(opts) {
19
+ const skewMs = opts.skewMs ?? DEFAULT_SKEW_MS;
20
+ const now = opts.now ?? Date.now;
21
+ let cached = null;
22
+ let inFlight = null;
23
+ let inFlightForced = false;
24
+ // Monotonic generation so a superseded in-flight acquire (a forceRefresh
25
+ // launched while a plain acquire was still running) cannot write its stale
26
+ // result back over the newer one.
27
+ let generation = 0;
28
+ const fresh = (t) => t.expiresAtMs == null || now() < t.expiresAtMs - skewMs;
29
+ const acquireShared = (force) => {
30
+ // A forced acquire must not join a non-forced in-flight one — that would
31
+ // serve the very credential a 401 just invalidated. A forced in-flight
32
+ // DOES satisfy non-forced waiters: it is already renewing.
33
+ if (inFlight && (inFlightForced || !force))
34
+ return inFlight;
35
+ const gen = ++generation;
36
+ const p = opts.acquire(force).then((t) => {
37
+ if (gen === generation)
38
+ cached = t;
39
+ return t;
40
+ });
41
+ inFlight = p;
42
+ inFlightForced = force;
43
+ // `then(cleanup, cleanup)` not `.finally(cleanup)`: finally propagates the
44
+ // rejection into a derived promise nobody awaits — an unhandledRejection.
45
+ const cleanup = () => {
46
+ if (inFlight === p) {
47
+ inFlight = null;
48
+ inFlightForced = false;
49
+ }
50
+ };
51
+ p.then(cleanup, cleanup);
52
+ return p;
53
+ };
54
+ return {
55
+ async getToken(forceRefresh = false) {
56
+ if (forceRefresh)
57
+ cached = null;
58
+ if (cached && fresh(cached))
59
+ return cached.accessToken;
60
+ return (await acquireShared(forceRefresh)).accessToken;
61
+ },
62
+ invalidate() {
63
+ cached = null;
64
+ },
65
+ };
66
+ }
@@ -0,0 +1,109 @@
1
+ /**
2
+ * Server-attested tool approval (OSK-11975 follow-up to the `manual` mode
3
+ * refusal on `daemon:tool:request`).
4
+ *
5
+ * A daemon category in `manual` mode means "a PERSON approves each call". On
6
+ * the harness-session path the daemon asks through the permission bridge; on
7
+ * the daemon-tool path a cloud agent's call used to be refused outright,
8
+ * because the request carried no approval and there was no channel back to a
9
+ * person. The attested-approval protocol closes that gap without weakening
10
+ * the guarantee:
11
+ *
12
+ * 1. The daemon announces `daemon_tool_attested_approval_v1` and its
13
+ * resolved per-category posture (`toolPermissionModes`) at register time.
14
+ * 2. Before proxying a call, the server reads the announced posture for the
15
+ * tool's category. `manual` means it must ask first — through the same
16
+ * permission surface (`canUseTool` → `permission:request`) an in-process
17
+ * SDK turn would have used — and only then dispatch, carrying an
18
+ * `attestedApproval` object on the request.
19
+ * 3. The daemon verifies the attestation's SHAPE and BINDING — it is bound
20
+ * to this exact (toolUseId, toolName, toolInput digest) and expires
21
+ * quickly — and allows the call. A missing or malformed attestation gets
22
+ * the same named refusal as before.
23
+ *
24
+ * What the daemon verifies is the binding, not the server's honesty: the
25
+ * request already arrives over the daemon's authenticated server channel, so
26
+ * the field IS the attestation. What the binding buys is that the model —
27
+ * which authors `toolInput` — cannot mint an approval, and an approval for
28
+ * one call cannot be replayed onto another.
29
+ */
30
+ /** Announced by a daemon that honours `attestedApproval` on daemon:tool:request. */
31
+ export declare const DAEMON_TOOL_ATTESTED_APPROVAL_CAPABILITY = "daemon_tool_attested_approval_v1";
32
+ /**
33
+ * Announced by a daemon that honours `toolPosture: 'read_only'` on
34
+ * daemon:tool:request — the daemon-tool-path expression of the wire `plan`
35
+ * session mode: reads run, every mutating category is refused with a named
36
+ * reason. Carries INTENT like `browserPolicy: 'read_only'` — the daemon owns
37
+ * what read-only means, so the server cannot widen the fence by shipping a
38
+ * wider value.
39
+ */
40
+ export declare const DAEMON_TOOL_READ_ONLY_CAPABILITY = "daemon_tool_read_only_v1";
41
+ /**
42
+ * Tool categories the daemon recognizes for permission purposes. This is the
43
+ * wire vocabulary the daemon's announced `toolPermissionModes` is keyed on —
44
+ * shared so the server's pre-dispatch check and the daemon's own enforcement
45
+ * classify a tool name identically (a third map was exactly the drift this
46
+ * package exists to prevent).
47
+ */
48
+ export type DaemonToolCategory = 'read' | 'write' | 'bash' | 'browser' | 'claude_session' | 'native_ui';
49
+ export declare const DAEMON_TOOL_CATEGORIES: readonly DaemonToolCategory[];
50
+ /** Map a tool name to its category. Unknown tools take the restrictive answer. */
51
+ export declare function categoryForDaemonTool(toolName: string): DaemonToolCategory;
52
+ /**
53
+ * The posture a daemon announces per category for the daemon-tool path:
54
+ * - `auto` — the call runs once guardrails pass; no attestation needed
55
+ * - `manual` — the call needs a person; the server must attach a fresh
56
+ * `attestedApproval` or the daemon refuses it
57
+ * - `delegated` — the category's own policy decides per action (browser,
58
+ * native UI); asking up front would ask the wrong question
59
+ */
60
+ export type DaemonToolPathPosture = 'auto' | 'manual' | 'delegated';
61
+ export type DaemonToolPermissionModes = Readonly<Partial<Record<DaemonToolCategory, DaemonToolPathPosture>>>;
62
+ /** The wire field values `toolPosture` accepts. Unknown values must be ignored. */
63
+ export type DaemonToolPosture = 'read_only';
64
+ /**
65
+ * How long an attestation stays valid. Short: it answers ONE person, about
66
+ * THIS call, moments ago — it is not a standing grant. The server asks and
67
+ * dispatches back-to-back, so the window only has to outlive publish latency.
68
+ */
69
+ export declare const ATTESTED_TOOL_APPROVAL_TTL_MS: number;
70
+ export interface AttestedToolApproval {
71
+ v: 1;
72
+ /** Binds the approval to this exact call — a different call gets nothing. */
73
+ toolUseId: string;
74
+ toolName: string;
75
+ /** SHA-256 of the canonical tool input (see `canonicalToolInputDigest`). */
76
+ inputSha256: string;
77
+ /** Who answered on the server's permission surface. */
78
+ decidedBy: 'user';
79
+ issuedAt: number;
80
+ expiresAt: number;
81
+ }
82
+ /** The digest both sides compute over `toolInput`. */
83
+ export declare function canonicalToolInputDigest(toolInput: unknown): string;
84
+ /** Server side: mint the attestation after a person approved THIS call. */
85
+ export declare function mintAttestedToolApproval(args: {
86
+ toolUseId: string;
87
+ toolName: string;
88
+ toolInput: unknown;
89
+ now?: number;
90
+ ttlMs?: number;
91
+ }): AttestedToolApproval;
92
+ export type AttestedToolApprovalVerdict = {
93
+ ok: true;
94
+ } | {
95
+ ok: false;
96
+ reason: 'absent' | 'malformed' | 'binding' | 'expired';
97
+ };
98
+ /**
99
+ * Daemon side: verify an attestation against the call it claims to approve.
100
+ * Untrusted wire input — every field is checked, and any mismatch is a plain
101
+ * "no approval", never a partial credit. `reason` is for daemon logs; the
102
+ * refusal text stays generic so a probing caller learns nothing new.
103
+ */
104
+ export declare function verifyAttestedToolApproval(approval: unknown, call: {
105
+ toolUseId: string;
106
+ toolName: string;
107
+ toolInput: unknown;
108
+ now?: number;
109
+ }): AttestedToolApprovalVerdict;
@@ -0,0 +1,164 @@
1
+ import { createHash } from 'node:crypto';
2
+ /**
3
+ * Server-attested tool approval (OSK-11975 follow-up to the `manual` mode
4
+ * refusal on `daemon:tool:request`).
5
+ *
6
+ * A daemon category in `manual` mode means "a PERSON approves each call". On
7
+ * the harness-session path the daemon asks through the permission bridge; on
8
+ * the daemon-tool path a cloud agent's call used to be refused outright,
9
+ * because the request carried no approval and there was no channel back to a
10
+ * person. The attested-approval protocol closes that gap without weakening
11
+ * the guarantee:
12
+ *
13
+ * 1. The daemon announces `daemon_tool_attested_approval_v1` and its
14
+ * resolved per-category posture (`toolPermissionModes`) at register time.
15
+ * 2. Before proxying a call, the server reads the announced posture for the
16
+ * tool's category. `manual` means it must ask first — through the same
17
+ * permission surface (`canUseTool` → `permission:request`) an in-process
18
+ * SDK turn would have used — and only then dispatch, carrying an
19
+ * `attestedApproval` object on the request.
20
+ * 3. The daemon verifies the attestation's SHAPE and BINDING — it is bound
21
+ * to this exact (toolUseId, toolName, toolInput digest) and expires
22
+ * quickly — and allows the call. A missing or malformed attestation gets
23
+ * the same named refusal as before.
24
+ *
25
+ * What the daemon verifies is the binding, not the server's honesty: the
26
+ * request already arrives over the daemon's authenticated server channel, so
27
+ * the field IS the attestation. What the binding buys is that the model —
28
+ * which authors `toolInput` — cannot mint an approval, and an approval for
29
+ * one call cannot be replayed onto another.
30
+ */
31
+ /** Announced by a daemon that honours `attestedApproval` on daemon:tool:request. */
32
+ export const DAEMON_TOOL_ATTESTED_APPROVAL_CAPABILITY = 'daemon_tool_attested_approval_v1';
33
+ /**
34
+ * Announced by a daemon that honours `toolPosture: 'read_only'` on
35
+ * daemon:tool:request — the daemon-tool-path expression of the wire `plan`
36
+ * session mode: reads run, every mutating category is refused with a named
37
+ * reason. Carries INTENT like `browserPolicy: 'read_only'` — the daemon owns
38
+ * what read-only means, so the server cannot widen the fence by shipping a
39
+ * wider value.
40
+ */
41
+ export const DAEMON_TOOL_READ_ONLY_CAPABILITY = 'daemon_tool_read_only_v1';
42
+ export const DAEMON_TOOL_CATEGORIES = [
43
+ 'read',
44
+ 'write',
45
+ 'bash',
46
+ 'browser',
47
+ 'claude_session',
48
+ 'native_ui',
49
+ ];
50
+ /** Map a tool name to its category. Unknown tools take the restrictive answer. */
51
+ export function categoryForDaemonTool(toolName) {
52
+ switch (String(toolName || '').toLowerCase()) {
53
+ case 'read':
54
+ case 'glob':
55
+ case 'grep':
56
+ case 'filetransferread':
57
+ return 'read';
58
+ case 'write':
59
+ case 'edit':
60
+ case 'multiedit':
61
+ case 'apply_patch':
62
+ case 'filetransferwrite':
63
+ return 'write';
64
+ // Reading or stopping a background command is part of running it.
65
+ case 'bash':
66
+ case 'taskoutput':
67
+ case 'taskstop':
68
+ return 'bash';
69
+ case 'browser':
70
+ return 'browser';
71
+ case 'native':
72
+ return 'native_ui';
73
+ case 'claude_session':
74
+ case 'claude-session':
75
+ return 'claude_session';
76
+ default:
77
+ return 'write';
78
+ }
79
+ }
80
+ // ---------------------------------------------------------------------------
81
+ // Attested approval
82
+ // ---------------------------------------------------------------------------
83
+ /**
84
+ * How long an attestation stays valid. Short: it answers ONE person, about
85
+ * THIS call, moments ago — it is not a standing grant. The server asks and
86
+ * dispatches back-to-back, so the window only has to outlive publish latency.
87
+ */
88
+ export const ATTESTED_TOOL_APPROVAL_TTL_MS = 60 * 1000;
89
+ /**
90
+ * Deterministic serialization for the input digest: objects sort keys
91
+ * recursively, arrays keep order, non-JSON values normalize the way
92
+ * `JSON.stringify` would emit them (undefined/functions in objects drop, in
93
+ * arrays become null). Both runtimes must digest the SAME bytes — the server
94
+ * mints and the daemon verifies — so this lives here, once.
95
+ */
96
+ function canonicalJson(value) {
97
+ if (value === null || typeof value !== 'object') {
98
+ if (value === undefined || typeof value === 'function' || typeof value === 'symbol') {
99
+ return 'null';
100
+ }
101
+ return JSON.stringify(value);
102
+ }
103
+ if (Array.isArray(value)) {
104
+ return `[${value.map((entry) => canonicalJson(entry)).join(',')}]`;
105
+ }
106
+ const record = value;
107
+ const keys = Object.keys(record).filter((key) => record[key] !== undefined &&
108
+ typeof record[key] !== 'function' &&
109
+ typeof record[key] !== 'symbol');
110
+ keys.sort();
111
+ return `{${keys.map((key) => `${JSON.stringify(key)}:${canonicalJson(record[key])}`).join(',')}}`;
112
+ }
113
+ /** The digest both sides compute over `toolInput`. */
114
+ export function canonicalToolInputDigest(toolInput) {
115
+ return createHash('sha256')
116
+ .update(canonicalJson(toolInput ?? {}))
117
+ .digest('hex');
118
+ }
119
+ /** Server side: mint the attestation after a person approved THIS call. */
120
+ export function mintAttestedToolApproval(args) {
121
+ const now = args.now ?? Date.now();
122
+ return {
123
+ v: 1,
124
+ toolUseId: args.toolUseId,
125
+ toolName: args.toolName,
126
+ inputSha256: canonicalToolInputDigest(args.toolInput),
127
+ decidedBy: 'user',
128
+ issuedAt: now,
129
+ expiresAt: now + (args.ttlMs ?? ATTESTED_TOOL_APPROVAL_TTL_MS),
130
+ };
131
+ }
132
+ /**
133
+ * Daemon side: verify an attestation against the call it claims to approve.
134
+ * Untrusted wire input — every field is checked, and any mismatch is a plain
135
+ * "no approval", never a partial credit. `reason` is for daemon logs; the
136
+ * refusal text stays generic so a probing caller learns nothing new.
137
+ */
138
+ export function verifyAttestedToolApproval(approval, call) {
139
+ if (approval === undefined || approval === null)
140
+ return { ok: false, reason: 'absent' };
141
+ if (typeof approval !== 'object' || Array.isArray(approval)) {
142
+ return { ok: false, reason: 'malformed' };
143
+ }
144
+ const a = approval;
145
+ if (a.v !== 1 ||
146
+ typeof a.toolUseId !== 'string' ||
147
+ typeof a.toolName !== 'string' ||
148
+ typeof a.inputSha256 !== 'string' ||
149
+ a.decidedBy !== 'user' ||
150
+ typeof a.issuedAt !== 'number' ||
151
+ typeof a.expiresAt !== 'number') {
152
+ return { ok: false, reason: 'malformed' };
153
+ }
154
+ const now = call.now ?? Date.now();
155
+ if (a.expiresAt <= now || a.issuedAt > now + 30_000) {
156
+ return { ok: false, reason: 'expired' };
157
+ }
158
+ if (a.toolUseId !== call.toolUseId ||
159
+ a.toolName.toLowerCase() !== String(call.toolName).toLowerCase() ||
160
+ a.inputSha256 !== canonicalToolInputDigest(call.toolInput)) {
161
+ return { ok: false, reason: 'binding' };
162
+ }
163
+ return { ok: true };
164
+ }
@@ -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;
@@ -12,6 +12,7 @@ const REASON_HUMAN = {
12
12
  REFRESH_REUSED: 'Your session was ended for security reasons (token replay detected).',
13
13
  REFRESH_EXPIRED: 'Your session has expired.',
14
14
  SESSION_REVOKED: 'Your session was ended (you signed out elsewhere or your password changed).',
15
+ DEVICE_KEY_MISMATCH: "This computer's device key no longer matches the one skrr registered for it, so its session cannot be renewed.",
15
16
  TOKEN_EXPIRED: 'Your access token has expired.',
16
17
  TOKEN_INVALID: 'Your saved credentials are no longer valid.',
17
18
  UNKNOWN_401: 'skrr needs you to sign in again.',
@@ -29,12 +30,16 @@ const REASON_HUMAN = {
29
30
  * something the other did not describe (OSK-10224).
30
31
  *
31
32
  * The binary is whatever the consumer registered with
32
- * `configureAuthCore({ binaryName })` — the binary the user actually typed —
33
- * and `--device` is added only when there is no TTY to open a browser from.
33
+ * `configureAuthCore({ binaryName })` — the binary the user actually typed.
34
+ *
35
+ * No flag. This used to append `--device` whenever stdout was not a TTY, which
36
+ * is always true for a daemon under launchd/systemd — and neither `skrrd login`
37
+ * nor `skrr login` has a `--device` option, so the one command the latch line
38
+ * named failed with `unknown option '--device'` (OSK-12056). Both logins already
39
+ * fall back to a paste-a-code arm when no browser can be opened.
34
40
  */
35
41
  export function reauthCommand() {
36
- const bin = getAuthBinaryName();
37
- return process.stdout.isTTY ? `${bin} login` : `${bin} login --device`;
42
+ return `${getAuthBinaryName()} login`;
38
43
  }
39
44
  export function formatReauthMessage(reason) {
40
45
  const norm = REASON_HUMAN[reason] || REASON_HUMAN.UNKNOWN;
@@ -25,6 +25,7 @@ import fs from 'node:fs';
25
25
  import path from 'node:path';
26
26
  import lockfile from 'proper-lockfile';
27
27
  import { getAuthLogger, getAuthConfigDir, emitAuthTelemetry, notifyResponseObserver, } from './runtime.js';
28
+ import { permanentReasonForRefreshCode } from './refreshClassification.js';
28
29
  import { PermanentAuthFailure, TransientAuthFailure, } from './types.js';
29
30
  /**
30
31
  * P1-6: Reduced from 30 s to 5 s.
@@ -804,14 +805,12 @@ async function doRefresh(serverUrl, currentRefreshToken, daemonId, options = {})
804
805
  catch {
805
806
  /* ignore */
806
807
  }
807
- const code = parsedBody.code;
808
- const permanent = [
809
- 'REFRESH_INVALID',
810
- 'REFRESH_REUSED',
811
- 'REFRESH_EXPIRED',
812
- 'SESSION_REVOKED',
813
- ];
814
- if (code && permanent.includes(code)) {
808
+ // The server's code, mapped through the one table the stateless classifier
809
+ // reads too — so a permanent refusal that is not spelled as a reason name
810
+ // (DEVICE_PROOF_THUMBPRINT_MISMATCH) latches instead of riding the
811
+ // unknown-401 budget forever (OSK-12056).
812
+ const code = permanentReasonForRefreshCode(parsedBody.code);
813
+ if (code) {
815
814
  // A KNOWN credential-death code is genuinely permanent — latch
816
815
  // immediately, exactly as before. It is NOT routed through the
817
816
  // unknown-401 budget.
@@ -841,7 +840,7 @@ async function doRefresh(serverUrl, currentRefreshToken, daemonId, options = {})
841
840
  }
842
841
  // Unknown-shape 401 — unknown code, missing code, or unparseable body.
843
842
  // By construction this is NOT a daemon's actual refresh-family burn
844
- // (those arrive as one of the four KNOWN codes above); it most likely
843
+ // (those arrive as one of the KNOWN codes above); it most likely
845
844
  // came from infra OverSky does not fully control — a proxy/middleware
846
845
  // regression, a clock-skew replay rejection (DEVICE_PROOF_REPLAYED),
847
846
  // 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.
@@ -33,6 +33,33 @@ const PERMANENT_CODE_SET = new Set([
33
33
  'REFRESH_EXPIRED',
34
34
  'SESSION_REVOKED',
35
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
+ export 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
+ }
36
63
  /**
37
64
  * Classify an HTTP response from /api/auth/refresh (or /api/daemons/token/refresh)
38
65
  * into a discriminated union of success or failure.
@@ -65,17 +92,12 @@ export function classifyRefreshResponse(status, body) {
65
92
  }
66
93
  // 401 — auth failure; check for known permanent code
67
94
  if (status === 401) {
68
- if (body?.code === 'REFRESH_MISSING') {
69
- return {
70
- kind: 'failure',
71
- failure: new PermanentAuthFailure('REFRESH_INVALID', body.message ?? 'Refresh token missing'),
72
- };
73
- }
74
- const code = body?.code;
75
- 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;
76
98
  return {
77
99
  kind: 'failure',
78
- failure: new PermanentAuthFailure(code, body?.message ?? code),
100
+ failure: new PermanentAuthFailure(reason, body?.message ?? fallback),
79
101
  };
80
102
  }
81
103
  // Unknown-shape 401 (no code, or a code we don't recognise). This is NOT a