@skrr-ai/cli 0.1.58 → 0.1.59
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/base-command.d.ts +0 -16
- package/dist/base-command.js +65 -11
- package/dist/commands/logout.d.ts +0 -46
- package/dist/commands/logout.js +21 -3
- package/dist/lib/agentic-stream.d.ts +42 -0
- package/dist/lib/agentic-stream.js +103 -14
- package/dist/lib/api-fetch.d.ts +11 -0
- package/dist/lib/api-fetch.js +98 -1
- package/dist/lib/daemonBrokerRefusal.d.ts +21 -0
- package/dist/lib/daemonBrokerRefusal.js +87 -2
- package/dist/lib/dedicated-guest.d.ts +13 -0
- package/dist/lib/dedicated-guest.js +22 -0
- package/dist/lib/dedicated-machines.js +17 -1
- package/dist/lib/dedicated-service.d.ts +1 -13
- package/dist/lib/dedicated-service.js +7 -19
- package/dist/lib/login.js +10 -1
- package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/credentialEnvelopeBridge.d.ts +41 -0
- package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/credentialEnvelopeBridge.js +147 -3
- package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/deviceIdentityBridge.d.ts +17 -0
- package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/deviceIdentityBridge.js +38 -0
- package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/index.d.ts +2 -2
- package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/index.js +5 -3
- package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/profileStateDir.d.ts +21 -1
- package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/profileStateDir.js +40 -7
- package/dist/node_modules/@skrr-ai/auth-core/dist/esm/credentialEnvelopeBridge.d.ts +41 -0
- package/dist/node_modules/@skrr-ai/auth-core/dist/esm/credentialEnvelopeBridge.js +147 -4
- package/dist/node_modules/@skrr-ai/auth-core/dist/esm/deviceIdentityBridge.d.ts +17 -0
- package/dist/node_modules/@skrr-ai/auth-core/dist/esm/deviceIdentityBridge.js +38 -1
- package/dist/node_modules/@skrr-ai/auth-core/dist/esm/index.d.ts +2 -2
- package/dist/node_modules/@skrr-ai/auth-core/dist/esm/index.js +2 -2
- package/dist/node_modules/@skrr-ai/auth-core/dist/esm/profileStateDir.d.ts +21 -1
- package/dist/node_modules/@skrr-ai/auth-core/dist/esm/profileStateDir.js +39 -6
- package/dist/node_modules/@skrr-ai/auth-core/package.json +1 -1
- package/dist/node_modules/@skrr-ai/data-provider/index.js +4391 -4363
- package/oclif.manifest.json +33201 -33201
- package/package.json +1 -1
package/dist/base-command.d.ts
CHANGED
|
@@ -20,22 +20,6 @@ type OclifFlagOutput = {
|
|
|
20
20
|
type OclifArgOutput = {
|
|
21
21
|
[name: string]: unknown;
|
|
22
22
|
};
|
|
23
|
-
/**
|
|
24
|
-
* Base class for every CLI command.
|
|
25
|
-
*
|
|
26
|
-
* Responsibilities:
|
|
27
|
-
* - Run the one-shot auth-out-of-config migration (Phase H).
|
|
28
|
-
* - Parse global flags (`--bare`, `--token`) from argv once and thread
|
|
29
|
-
* them through the credential resolver and the HTTP adapter.
|
|
30
|
-
* - Resolve the active credential via the CredentialResolver chain —
|
|
31
|
-
* flag → env → helper → keychain → file (Phase H.1). Returns the
|
|
32
|
-
* source name for display in `skrr whoami` / `skrr doctor`.
|
|
33
|
-
* - Wire the Node HttpAdapter into @skrr-ai/data-provider so
|
|
34
|
-
* `dataService.*` calls use our fetch-based transport with bearer
|
|
35
|
-
* auth that matches what the resolver surfaced.
|
|
36
|
-
* - Provide `requireAuth()` for commands that need a signed-in user,
|
|
37
|
-
* and `handleApiError()` for uniform error translation.
|
|
38
|
-
*/
|
|
39
23
|
export declare abstract class BaseCommand extends Command {
|
|
40
24
|
/** Add the owning agent's web detail link without overwriting resource URLs. */
|
|
41
25
|
protected withAgentContext<T>(agentId: string, value: T): T & {
|
package/dist/base-command.js
CHANGED
|
@@ -16,6 +16,7 @@ exports.isDaemonScopedCredential = isDaemonScopedCredential;
|
|
|
16
16
|
exports.describeDaemonScopedCredential = describeDaemonScopedCredential;
|
|
17
17
|
exports.isTypedServerRefusal = isTypedServerRefusal;
|
|
18
18
|
exports.isRetryableFailure = isRetryableFailure;
|
|
19
|
+
const node_fs_1 = require("node:fs");
|
|
19
20
|
const core_1 = require("@oclif/core");
|
|
20
21
|
Object.defineProperty(exports, "Flags", { enumerable: true, get: function () { return core_1.Flags; } });
|
|
21
22
|
const option_hint_1 = require("./lib/option-hint");
|
|
@@ -51,6 +52,7 @@ const update_check_1 = require("./lib/update-check");
|
|
|
51
52
|
const web_url_1 = require("./lib/web-url");
|
|
52
53
|
const edge_error_page_1 = require("./lib/edge-error-page");
|
|
53
54
|
const delegated_cli_1 = require("./lib/delegated-cli");
|
|
55
|
+
const dedicated_guest_1 = require("./lib/dedicated-guest");
|
|
54
56
|
const tasks_1 = require("./lib/tasks");
|
|
55
57
|
function assigneePayloadNeedsResolution(payload) {
|
|
56
58
|
const fields = [
|
|
@@ -82,6 +84,25 @@ function assigneePayloadNeedsResolution(payload) {
|
|
|
82
84
|
* - Provide `requireAuth()` for commands that need a signed-in user,
|
|
83
85
|
* and `handleApiError()` for uniform error translation.
|
|
84
86
|
*/
|
|
87
|
+
/**
|
|
88
|
+
* On a Dedicated Runtime guest, the description of a daemon that did not
|
|
89
|
+
* answer the hand-off at all — revoked and exited, cycling under systemd, or
|
|
90
|
+
* gone behind a stale descriptor (OSK-12473). Null anywhere else, where "Not
|
|
91
|
+
* signed in. Run `skrr login` first." is the right sentence.
|
|
92
|
+
*/
|
|
93
|
+
function describeAbsentGuestDaemon(outcome, bin) {
|
|
94
|
+
const onGuest = outcome.dedicatedGuest === true || (0, dedicated_guest_1.runningOnDedicatedRuntimeGuest)();
|
|
95
|
+
if (!onGuest)
|
|
96
|
+
return null;
|
|
97
|
+
let descriptorPresent = false;
|
|
98
|
+
try {
|
|
99
|
+
descriptorPresent = (0, node_fs_1.existsSync)(daemonBroker_1.DEDICATED_RUNTIME_CLI_HANDOFF_DESCRIPTOR);
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
/* unreadable reads as absent */
|
|
103
|
+
}
|
|
104
|
+
return (0, daemonBrokerRefusal_1.describeGuestDaemonUnavailable)(outcome, { bin, onGuest, descriptorPresent });
|
|
105
|
+
}
|
|
85
106
|
class BaseCommand extends core_1.Command {
|
|
86
107
|
/** Add the owning agent's web detail link without overwriting resource URLs. */
|
|
87
108
|
withAgentContext(agentId, value) {
|
|
@@ -375,7 +396,9 @@ class BaseCommand extends core_1.Command {
|
|
|
375
396
|
}
|
|
376
397
|
: null;
|
|
377
398
|
}
|
|
378
|
-
else if ((this.brokerRefusal =
|
|
399
|
+
else if ((this.brokerRefusal =
|
|
400
|
+
(0, daemonBrokerRefusal_1.describeBrokerRefusal)(autoResult.outcome, this.config.bin) ??
|
|
401
|
+
describeAbsentGuestDaemon(autoResult.outcome, this.config.bin))) {
|
|
379
402
|
// Reported by requireAuth(); nothing to print here.
|
|
380
403
|
}
|
|
381
404
|
else if (autoResult.outcome.reason === 'rate_limited') {
|
|
@@ -1140,19 +1163,45 @@ class BaseCommand extends core_1.Command {
|
|
|
1140
1163
|
bin: this.config.bin,
|
|
1141
1164
|
cliId: this.cliConfig ? (0, cli_id_1.resolveCliId)(this.cliConfig) : undefined,
|
|
1142
1165
|
});
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1166
|
+
// OSK-12474 — `apiFetch` asked the local daemon for a hand-off after the
|
|
1167
|
+
// refused refresh, and the daemon refused too. Its reason decides the
|
|
1168
|
+
// repair (OSK-12149, OSK-12151), and on a Dedicated guest there is no
|
|
1169
|
+
// login to run, so its remedy replaces the generic "Run login".
|
|
1170
|
+
const brokerRefusal = presentedCiToken || !e.brokerFailure
|
|
1171
|
+
? null
|
|
1172
|
+
: (0, daemonBrokerRefusal_1.describeBrokerRefusal)(e.brokerFailure, this.config.bin);
|
|
1173
|
+
const renewal = refreshRefusal
|
|
1174
|
+
? refreshRefusal.message.replace(/\s*Run `[^`]+` to sign in again\.$/, '')
|
|
1175
|
+
: "This CLI's own sign-in could not be renewed.";
|
|
1176
|
+
const details = {
|
|
1177
|
+
...(refreshRefusal && bodyCode ? { accessTokenCode: bodyCode } : {}),
|
|
1178
|
+
...(brokerRefusal
|
|
1179
|
+
? { broker: brokerRefusal.broker, suggestion: brokerRefusal.remedy }
|
|
1180
|
+
: {}),
|
|
1181
|
+
};
|
|
1182
|
+
let authMessage;
|
|
1183
|
+
if (presentedCiToken) {
|
|
1184
|
+
authMessage =
|
|
1185
|
+
`Authentication failed: this is an \`osk_ci_*\` token, which only bootstraps a ` +
|
|
1146
1186
|
`daemon (\`OVERSKY_TOKEN=<token> skrrd start\`). ${this.config.bin} commands do ` +
|
|
1147
|
-
`not accept it — sign in with \`${this.config.bin} login\` instead
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1187
|
+
`not accept it — sign in with \`${this.config.bin} login\` instead.`;
|
|
1188
|
+
}
|
|
1189
|
+
else if (brokerRefusal) {
|
|
1190
|
+
authMessage = `Authentication failed. ${renewal} ${brokerRefusal.headline}\n→ ${brokerRefusal.remedy}`;
|
|
1191
|
+
}
|
|
1192
|
+
else if (refreshRefusal) {
|
|
1193
|
+
authMessage = `Authentication failed. ${refreshRefusal.message}`;
|
|
1194
|
+
}
|
|
1195
|
+
else {
|
|
1196
|
+
authMessage = `Authentication failed. Run \`${this.config.bin} login\` to sign in again.`;
|
|
1197
|
+
}
|
|
1198
|
+
this.failWithCliError({
|
|
1199
|
+
message: say(authMessage),
|
|
1151
1200
|
code: refreshRefusal?.code || bodyCode || 'AUTHENTICATION_FAILED',
|
|
1152
1201
|
status: e.status,
|
|
1153
1202
|
exit: exitCode,
|
|
1154
1203
|
retryable: true,
|
|
1155
|
-
...(
|
|
1204
|
+
...(Object.keys(details).length > 0 ? { details } : {}),
|
|
1156
1205
|
});
|
|
1157
1206
|
}
|
|
1158
1207
|
else if ((e.status === 403 || e.status === 504) &&
|
|
@@ -1438,9 +1487,14 @@ class BaseCommand extends core_1.Command {
|
|
|
1438
1487
|
bin: this.config.bin,
|
|
1439
1488
|
cliId: this.cliConfig ? (0, cli_id_1.resolveCliId)(this.cliConfig) : undefined,
|
|
1440
1489
|
});
|
|
1441
|
-
const
|
|
1490
|
+
const brokerRefusal = e.brokerFailure
|
|
1491
|
+
? (0, daemonBrokerRefusal_1.describeBrokerRefusal)(e.brokerFailure, this.config.bin)
|
|
1492
|
+
: null;
|
|
1493
|
+
const why = (refusal
|
|
1442
1494
|
? refusal.message.replace(/\s*Run `[^`]+` to sign in again\.$/, '')
|
|
1443
|
-
: 'The server did not accept this sign-in.'
|
|
1495
|
+
: 'The server did not accept this sign-in.') +
|
|
1496
|
+
// OSK-12474 — the daemon was asked for a hand-off and refused; say why.
|
|
1497
|
+
(brokerRefusal ? ` ${brokerRefusal.headline}` : '');
|
|
1444
1498
|
this.failWithCliError({
|
|
1445
1499
|
message: `Authentication failed${statusSuffix}. ${why} Your ${write.summary} was not applied; it ` +
|
|
1446
1500
|
`was saved locally as ${queuedId} so it is not lost. Sign in with ` +
|
|
@@ -5,52 +5,6 @@ export declare function shouldSkipLocalLogout(input: {
|
|
|
5
5
|
hasUnreadableEncryptedToken: boolean;
|
|
6
6
|
allDevices: boolean;
|
|
7
7
|
}): boolean;
|
|
8
|
-
/**
|
|
9
|
-
* `skrr logout` / `skrr logout --all-devices`
|
|
10
|
-
*
|
|
11
|
-
* Default mode (no flags): revokes the current CLI refresh session on the
|
|
12
|
-
* server (best-effort) and clears every local backend (Keychain on macOS,
|
|
13
|
-
* cli-auth.json fallback, and any legacy token fields in cli-config.json).
|
|
14
|
-
* `baseURL` and `cliId` are preserved so the next `skrr login` defaults to
|
|
15
|
-
* the same host and reuses the per-machine identity (which becomes a new
|
|
16
|
-
* refresh family — server-side revocation invalidated the prior family).
|
|
17
|
-
*
|
|
18
|
-
* `--all-devices` (L7 fleet-grade logout): in addition to the local
|
|
19
|
-
* cleanup, iterate the user's registered daemons and revoke each one
|
|
20
|
-
* server-side. This is the threat-model-correct response for a stolen-
|
|
21
|
-
* laptop scenario where the user wants every persistent daemon credential
|
|
22
|
-
* gone immediately, regardless of which machine they're calling from.
|
|
23
|
-
*
|
|
24
|
-
* Architectural note: the L7 server-side has a bulk endpoint
|
|
25
|
-
* (`POST /api/auth/sessions/revoke-all`) but it requires a user-scope JWT.
|
|
26
|
-
* CLI sessions are minted with `scope: 'cli'` (interactive single-machine
|
|
27
|
-
* use), so they cannot directly hit the bulk endpoint — that's by design,
|
|
28
|
-
* preventing a leaked CLI credential from kicking the user out of every
|
|
29
|
-
* other device. We instead iterate `DELETE /api/daemon-resources/:id`
|
|
30
|
-
* which CLI scope CAN hit (per-resource ownership check, not scope check).
|
|
31
|
-
* N round trips for N daemons; for typical fleet sizes (< 20) this is
|
|
32
|
-
* sub-second. Each per-daemon DELETE produces its own audit row keyed by
|
|
33
|
-
* `'user_initiated_per_device'`, which preserves forensic granularity.
|
|
34
|
-
*
|
|
35
|
-
* The CLI's own refresh session is NOT a Daemon resource (it's a
|
|
36
|
-
* `scope='cli'` refresh-session row with no Daemon FK), so it's invisible
|
|
37
|
-
* to `/api/daemon-resources`. We revoke it separately via
|
|
38
|
-
* `revokeCurrentRefreshToken()`.
|
|
39
|
-
*
|
|
40
|
-
* Ordering rationale for `--all-devices`:
|
|
41
|
-
* 1. `runAllDevices` is attempted first (remote sweep).
|
|
42
|
-
* 2. If it throws (network error, API unreachable), the error is captured
|
|
43
|
-
* and a warning is emitted — but execution CONTINUES to step 3.
|
|
44
|
-
* Rationale: a user who runs `logout --all-devices` after a security
|
|
45
|
-
* incident must not be left with live tokens on disk just because the
|
|
46
|
-
* API was momentarily unreachable. Local cleanup is always the safer
|
|
47
|
-
* default; re-running --all-devices later from a connected machine is
|
|
48
|
-
* idempotent and cleans up whatever the first attempt missed.
|
|
49
|
-
* 3. If the user explicitly declines the confirmation prompt inside
|
|
50
|
-
* `runAllDevices`, that path calls `this.exit(1)` which invokes
|
|
51
|
-
* `process.exit` — the try/catch is never reached, so local cleanup
|
|
52
|
-
* is correctly skipped (user said "abort").
|
|
53
|
-
*/
|
|
54
8
|
export default class Logout extends BaseCommand {
|
|
55
9
|
static description: string;
|
|
56
10
|
static flags: {
|
package/dist/commands/logout.js
CHANGED
|
@@ -40,6 +40,7 @@ const config_1 = require("../lib/config");
|
|
|
40
40
|
const first_party_harness_managed_1 = require("../lib/first-party-harness-managed");
|
|
41
41
|
const auth_storage_1 = require("../lib/auth-storage");
|
|
42
42
|
const daemonBroker_1 = require("../lib/daemonBroker");
|
|
43
|
+
const dedicated_service_1 = require("../lib/dedicated-service");
|
|
43
44
|
const refresh_1 = require("../lib/refresh");
|
|
44
45
|
const api_fetch_1 = require("../lib/api-fetch");
|
|
45
46
|
const cred_envelope_1 = require("../lib/cred-envelope");
|
|
@@ -97,6 +98,18 @@ function shouldSkipLocalLogout(input) {
|
|
|
97
98
|
* `process.exit` — the try/catch is never reached, so local cleanup
|
|
98
99
|
* is correctly skipped (user said "abort").
|
|
99
100
|
*/
|
|
101
|
+
/**
|
|
102
|
+
* True only on a Dedicated Runtime guest whose platform hand-off can still
|
|
103
|
+
* serve this CLI. A laptop daemon's island file ALSO advertises
|
|
104
|
+
* `access_token`, so the descriptor alone is not evidence of a platform-managed
|
|
105
|
+
* machine: on a laptop, gating on it alone told a person who had just signed
|
|
106
|
+
* out that "skrr keeps working here through the platform's managed hand-off"
|
|
107
|
+
* (OSK-12467). The guest markers are what make that sentence true.
|
|
108
|
+
*/
|
|
109
|
+
function platformManagedHandoff() {
|
|
110
|
+
return (0, dedicated_service_1.runningOnDedicatedRuntimeGuest)() && Boolean((0, daemonBroker_1.findBrokeredHandoffDescriptor)());
|
|
111
|
+
}
|
|
112
|
+
const SIGN_IN_AGAIN = 'To sign in again, run `skrr login`.';
|
|
100
113
|
class Logout extends base_command_1.BaseCommand {
|
|
101
114
|
static description = 'Sign out and clear locally-stored tokens. ' +
|
|
102
115
|
'--all-devices also revokes every registered daemon for this account.';
|
|
@@ -134,12 +147,12 @@ class Logout extends base_command_1.BaseCommand {
|
|
|
134
147
|
// `access_token`) there IS no local credential to clear — the CLI holds
|
|
135
148
|
// a per-process token redeemed over loopback and nothing is persisted.
|
|
136
149
|
// Say that honestly rather than reporting a logout that did not happen.
|
|
137
|
-
if ((
|
|
150
|
+
if (platformManagedHandoff()) {
|
|
138
151
|
this.log("This machine is signed in through the platform's managed hand-off; " +
|
|
139
152
|
'there is no local credential to clear.');
|
|
140
153
|
}
|
|
141
154
|
else {
|
|
142
|
-
this.log(
|
|
155
|
+
this.log(`Not signed in. Nothing to do. ${SIGN_IN_AGAIN}`);
|
|
143
156
|
}
|
|
144
157
|
return;
|
|
145
158
|
}
|
|
@@ -261,10 +274,15 @@ class Logout extends base_command_1.BaseCommand {
|
|
|
261
274
|
// OWN (an explicit `skrr login`); the machine's platform-managed access is
|
|
262
275
|
// untouched and skrr keeps working through it. Say both, so "Signed out."
|
|
263
276
|
// is not read as "this machine can no longer reach skrr" (OSK-12043).
|
|
264
|
-
|
|
277
|
+
// Anywhere else there is no such hand-off, so say how to sign back in
|
|
278
|
+
// instead (OSK-12467).
|
|
279
|
+
if (platformManagedHandoff()) {
|
|
265
280
|
this.log('Removed your own sign-in from this machine. ' +
|
|
266
281
|
"skrr keeps working here through the platform's managed hand-off.");
|
|
267
282
|
}
|
|
283
|
+
else {
|
|
284
|
+
this.log(SIGN_IN_AGAIN);
|
|
285
|
+
}
|
|
268
286
|
if (sweptLegacy.length > 0) {
|
|
269
287
|
this.log(`Also removed ${sweptLegacy.length} credential${sweptLegacy.length === 1 ? '' : 's'} left by the previous product identity:`);
|
|
270
288
|
for (const service of sweptLegacy)
|
|
@@ -93,7 +93,35 @@ export type AgenticStreamOptions = {
|
|
|
93
93
|
idleTerminalProbeMs?: number;
|
|
94
94
|
/** How long to keep trying to attach before reporting a detach. Default 30s. */
|
|
95
95
|
attachTimeoutMs?: number;
|
|
96
|
+
/**
|
|
97
|
+
* How long a prompt listens to (and throws away) input before it is shown.
|
|
98
|
+
* Default {@link TYPE_AHEAD_DISCARD_MS}. See {@link discardTypeAhead}.
|
|
99
|
+
*/
|
|
100
|
+
typeAheadDiscardMs?: number;
|
|
96
101
|
};
|
|
102
|
+
/**
|
|
103
|
+
* How long input arriving just before a prompt is shown is thrown away.
|
|
104
|
+
*
|
|
105
|
+
* Long enough for bytes already typed into the terminal to be read out of the
|
|
106
|
+
* kernel once stdin starts flowing (one event-loop turn in practice), short
|
|
107
|
+
* enough that nobody waits for it.
|
|
108
|
+
*/
|
|
109
|
+
export declare const TYPE_AHEAD_DISCARD_MS = 60;
|
|
110
|
+
/**
|
|
111
|
+
* Throw away whatever was typed BEFORE a prompt is shown (OSK-12468).
|
|
112
|
+
*
|
|
113
|
+
* A key pressed while the turn was still streaming sits in the stream's buffer
|
|
114
|
+
* or the terminal's, and the first `readline.question` read it as the answer:
|
|
115
|
+
* an owner typed `y` before any `[y/N]` was visible and approved a Bash command
|
|
116
|
+
* they had never seen. An approval is only consent to what was on the screen,
|
|
117
|
+
* so every prompt now starts from an empty input: bytes the stream already
|
|
118
|
+
* holds are read and dropped, then stdin flows into a discarding listener for
|
|
119
|
+
* `windowMs` so the terminal's own buffer is drained too. Raw mode is switched
|
|
120
|
+
* on for that window because a half-typed line (`y`, no Enter) is invisible to
|
|
121
|
+
* a read in canonical mode and would otherwise surface as soon as readline
|
|
122
|
+
* enters raw mode itself. A Ctrl-C in the discarded input is still honoured.
|
|
123
|
+
*/
|
|
124
|
+
export declare function discardTypeAhead(input: NodeJS.ReadableStream, windowMs?: number): Promise<void>;
|
|
97
125
|
/**
|
|
98
126
|
* What a permission request would DO, in one line a person can judge.
|
|
99
127
|
*
|
|
@@ -467,7 +495,21 @@ export declare class AgenticStreamClient {
|
|
|
467
495
|
* client is still showing it — the question twin of handlePermissionCancel.
|
|
468
496
|
*/
|
|
469
497
|
private handleQuestionCancel;
|
|
498
|
+
/** Prompts run one at a time: two readlines on one stdin would both read a line. */
|
|
499
|
+
private askQueue;
|
|
470
500
|
private ask;
|
|
501
|
+
/**
|
|
502
|
+
* One prompt, on a readline that exists only while the prompt is on screen.
|
|
503
|
+
*
|
|
504
|
+
* The interface used to be created at the first prompt and kept until the
|
|
505
|
+
* run closed, and it was created AFTER stdin had been accumulating whatever
|
|
506
|
+
* the person typed while the turn streamed — so the first question answered
|
|
507
|
+
* itself from that type-ahead (OSK-12468). Now the input is emptied first
|
|
508
|
+
* ({@link discardTypeAhead}), only then is the question shown, and the
|
|
509
|
+
* interface is closed with the answer, so nothing typed between two prompts
|
|
510
|
+
* can answer the second one either.
|
|
511
|
+
*/
|
|
512
|
+
private askNow;
|
|
471
513
|
/**
|
|
472
514
|
* True the first time this (kind, identity) pair is seen; false afterwards.
|
|
473
515
|
* The identity is the event's OWN — a tool-use id, a message id — never a
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.AgenticStreamClient = exports.USER_CONNECTION_CAP_CODE = exports.NON_INTERACTIVE_QUESTION_DISMISSAL = void 0;
|
|
3
|
+
exports.AgenticStreamClient = exports.USER_CONNECTION_CAP_CODE = exports.TYPE_AHEAD_DISCARD_MS = exports.NON_INTERACTIVE_QUESTION_DISMISSAL = void 0;
|
|
4
4
|
exports.writeJsonLine = writeJsonLine;
|
|
5
|
+
exports.discardTypeAhead = discardTypeAhead;
|
|
5
6
|
exports.describePermissionTarget = describePermissionTarget;
|
|
6
7
|
exports.envelopesText = envelopesText;
|
|
7
8
|
exports.envelopeKey = envelopeKey;
|
|
@@ -54,6 +55,59 @@ exports.NON_INTERACTIVE_QUESTION_DISMISSAL = 'No answer: this turn was started n
|
|
|
54
55
|
function writeJsonLine(output, value) {
|
|
55
56
|
output.write(`${JSON.stringify(value) ?? 'null'}\n`);
|
|
56
57
|
}
|
|
58
|
+
/**
|
|
59
|
+
* How long input arriving just before a prompt is shown is thrown away.
|
|
60
|
+
*
|
|
61
|
+
* Long enough for bytes already typed into the terminal to be read out of the
|
|
62
|
+
* kernel once stdin starts flowing (one event-loop turn in practice), short
|
|
63
|
+
* enough that nobody waits for it.
|
|
64
|
+
*/
|
|
65
|
+
exports.TYPE_AHEAD_DISCARD_MS = 60;
|
|
66
|
+
/**
|
|
67
|
+
* Throw away whatever was typed BEFORE a prompt is shown (OSK-12468).
|
|
68
|
+
*
|
|
69
|
+
* A key pressed while the turn was still streaming sits in the stream's buffer
|
|
70
|
+
* or the terminal's, and the first `readline.question` read it as the answer:
|
|
71
|
+
* an owner typed `y` before any `[y/N]` was visible and approved a Bash command
|
|
72
|
+
* they had never seen. An approval is only consent to what was on the screen,
|
|
73
|
+
* so every prompt now starts from an empty input: bytes the stream already
|
|
74
|
+
* holds are read and dropped, then stdin flows into a discarding listener for
|
|
75
|
+
* `windowMs` so the terminal's own buffer is drained too. Raw mode is switched
|
|
76
|
+
* on for that window because a half-typed line (`y`, no Enter) is invisible to
|
|
77
|
+
* a read in canonical mode and would otherwise surface as soon as readline
|
|
78
|
+
* enters raw mode itself. A Ctrl-C in the discarded input is still honoured.
|
|
79
|
+
*/
|
|
80
|
+
async function discardTypeAhead(input, windowMs = exports.TYPE_AHEAD_DISCARD_MS) {
|
|
81
|
+
const tty = input;
|
|
82
|
+
const canSetRaw = Boolean(tty.isTTY) && typeof tty.setRawMode === 'function';
|
|
83
|
+
const wasRaw = canSetRaw ? Boolean(tty.isRaw) : false;
|
|
84
|
+
let sawInterrupt = false;
|
|
85
|
+
const drop = (chunk) => {
|
|
86
|
+
const text = typeof chunk === 'string' ? chunk : Buffer.isBuffer(chunk) ? chunk.toString() : '';
|
|
87
|
+
if (text.includes('\u0003'))
|
|
88
|
+
sawInterrupt = true;
|
|
89
|
+
};
|
|
90
|
+
try {
|
|
91
|
+
if (canSetRaw && !wasRaw)
|
|
92
|
+
tty.setRawMode(true);
|
|
93
|
+
const readable = input;
|
|
94
|
+
if (typeof readable.read === 'function') {
|
|
95
|
+
for (let chunk = readable.read(); chunk !== null; chunk = readable.read())
|
|
96
|
+
drop(chunk);
|
|
97
|
+
}
|
|
98
|
+
input.on('data', drop);
|
|
99
|
+
input.resume();
|
|
100
|
+
await new Promise((resolve) => setTimeout(resolve, Math.max(0, windowMs)));
|
|
101
|
+
}
|
|
102
|
+
finally {
|
|
103
|
+
input.removeListener('data', drop);
|
|
104
|
+
input.pause();
|
|
105
|
+
if (canSetRaw && !wasRaw)
|
|
106
|
+
tty.setRawMode(false);
|
|
107
|
+
}
|
|
108
|
+
if (sawInterrupt)
|
|
109
|
+
process.kill(process.pid, 'SIGINT');
|
|
110
|
+
}
|
|
57
111
|
const PERMISSION_DETAIL_MAX = 400;
|
|
58
112
|
/**
|
|
59
113
|
* What a permission request would DO, in one line a person can judge.
|
|
@@ -266,6 +320,13 @@ function describeToolEnd(toolName, status) {
|
|
|
266
320
|
return `[tool failed] ${name}`;
|
|
267
321
|
case 'cancelled':
|
|
268
322
|
return `[tool cancelled] ${name}`;
|
|
323
|
+
// OSK-12469 — a call whose approval was refused never ran. It printed
|
|
324
|
+
// `[tool completed]` just before the line saying it was denied; a call
|
|
325
|
+
// whose ask expired, or whose turn a deploy paused, did the same.
|
|
326
|
+
case 'denied':
|
|
327
|
+
return `[tool denied] ${name} (it did not run)`;
|
|
328
|
+
case 'not_run':
|
|
329
|
+
return `[tool not run] ${name}`;
|
|
269
330
|
case 'unreported':
|
|
270
331
|
return `[tool result not reported] ${name} (the harness never said how it ended; closed when the session ended)`;
|
|
271
332
|
default:
|
|
@@ -1151,26 +1212,54 @@ class AgenticStreamClient {
|
|
|
1151
1212
|
promptClosed: Boolean(open),
|
|
1152
1213
|
});
|
|
1153
1214
|
}
|
|
1215
|
+
/** Prompts run one at a time: two readlines on one stdin would both read a line. */
|
|
1216
|
+
askQueue = Promise.resolve();
|
|
1154
1217
|
ask(prompt, signal) {
|
|
1218
|
+
const next = this.askQueue.then(() => this.askNow(prompt, signal));
|
|
1219
|
+
this.askQueue = next.catch(() => undefined);
|
|
1220
|
+
return next;
|
|
1221
|
+
}
|
|
1222
|
+
/**
|
|
1223
|
+
* One prompt, on a readline that exists only while the prompt is on screen.
|
|
1224
|
+
*
|
|
1225
|
+
* The interface used to be created at the first prompt and kept until the
|
|
1226
|
+
* run closed, and it was created AFTER stdin had been accumulating whatever
|
|
1227
|
+
* the person typed while the turn streamed — so the first question answered
|
|
1228
|
+
* itself from that type-ahead (OSK-12468). Now the input is emptied first
|
|
1229
|
+
* ({@link discardTypeAhead}), only then is the question shown, and the
|
|
1230
|
+
* interface is closed with the answer, so nothing typed between two prompts
|
|
1231
|
+
* can answer the second one either.
|
|
1232
|
+
*/
|
|
1233
|
+
async askNow(prompt, signal) {
|
|
1155
1234
|
// A prompt raised after close has nobody to answer it, and re-creating the
|
|
1156
1235
|
// interface here would resurrect stdin for a run that is already over.
|
|
1157
1236
|
if (this.closed || signal?.aborted)
|
|
1158
|
-
return
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1237
|
+
return '';
|
|
1238
|
+
const input = this.options.input ?? process.stdin;
|
|
1239
|
+
await discardTypeAhead(input, this.options.typeAheadDiscardMs ?? exports.TYPE_AHEAD_DISCARD_MS);
|
|
1240
|
+
if (this.closed || signal?.aborted)
|
|
1241
|
+
return '';
|
|
1242
|
+
const rl = (0, node_readline_1.createInterface)({ input, output: process.stderr });
|
|
1243
|
+
this.readline = rl;
|
|
1244
|
+
try {
|
|
1245
|
+
return await new Promise((resolve) => {
|
|
1246
|
+
// Closed under us (the run ended): nobody is left to answer.
|
|
1247
|
+
rl.once('close', () => resolve(''));
|
|
1248
|
+
if (!signal) {
|
|
1249
|
+
rl.question(prompt, resolve);
|
|
1250
|
+
return;
|
|
1251
|
+
}
|
|
1252
|
+
// readline does not call back for a question aborted by its signal, so
|
|
1253
|
+
// the abort settles the prompt itself.
|
|
1254
|
+
signal.addEventListener('abort', () => resolve(''), { once: true });
|
|
1255
|
+
rl.question(prompt, { signal }, resolve);
|
|
1163
1256
|
});
|
|
1164
1257
|
}
|
|
1165
|
-
|
|
1166
|
-
|
|
1258
|
+
finally {
|
|
1259
|
+
if (this.readline === rl)
|
|
1260
|
+
this.readline = null;
|
|
1261
|
+
rl.close();
|
|
1167
1262
|
}
|
|
1168
|
-
return new Promise((resolve) => {
|
|
1169
|
-
// readline does not call back for a question aborted by its signal, so
|
|
1170
|
-
// the abort settles the prompt itself.
|
|
1171
|
-
signal.addEventListener('abort', () => resolve(''), { once: true });
|
|
1172
|
-
this.readline.question(prompt, { signal }, resolve);
|
|
1173
|
-
});
|
|
1174
1263
|
}
|
|
1175
1264
|
/**
|
|
1176
1265
|
* True the first time this (kind, identity) pair is seen; false afterwards.
|
package/dist/lib/api-fetch.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { type ResolvedCredential } from './credential-resolver';
|
|
2
|
+
import { type DaemonBrokerFailure } from './daemonBroker';
|
|
2
3
|
export interface ApiFetchOptions {
|
|
3
4
|
method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
|
|
4
5
|
body?: unknown;
|
|
@@ -36,6 +37,14 @@ export declare class ApiFetchError extends Error {
|
|
|
36
37
|
* attributed by guesswork — see `node-adapter.ts`.
|
|
37
38
|
*/
|
|
38
39
|
readonly requestId?: string;
|
|
40
|
+
/**
|
|
41
|
+
* Set on a 401 when this CLI's own refresh was refused AND the local
|
|
42
|
+
* daemon, asked for a hand-off, refused too (OSK-12474). The daemon's reason
|
|
43
|
+
* decides the repair, so the 401 message carries it rather than a bare
|
|
44
|
+
* "Run `skrr login`" (OSK-12149, OSK-12151); `describeBrokerRefusal` turns
|
|
45
|
+
* it into words. Absent when the daemon was never asked.
|
|
46
|
+
*/
|
|
47
|
+
brokerFailure?: DaemonBrokerFailure;
|
|
39
48
|
constructor(message: string, status: number, body?: string, code?: string, method?: string, requestId?: string);
|
|
40
49
|
}
|
|
41
50
|
/**
|
|
@@ -44,6 +53,8 @@ export declare class ApiFetchError extends Error {
|
|
|
44
53
|
* `err.status` / `err.code`.
|
|
45
54
|
*/
|
|
46
55
|
export declare function apiFetch<T>(pathOrUrl: string, opts?: ApiFetchOptions): Promise<T>;
|
|
56
|
+
/** @internal test seam — forget this process's daemon hand-off attempt. */
|
|
57
|
+
export declare function __resetDaemonHandoffFallbackForTest(): void;
|
|
47
58
|
/**
|
|
48
59
|
* True when the current process has a persisted refresh-capable session
|
|
49
60
|
* (`skrr login` has been run and survived). Used by CI-token commands
|
package/dist/lib/api-fetch.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.ApiFetchError = void 0;
|
|
4
4
|
exports.apiFetch = apiFetch;
|
|
5
|
+
exports.__resetDaemonHandoffFallbackForTest = __resetDaemonHandoffFallbackForTest;
|
|
5
6
|
exports.hasHumanSession = hasHumanSession;
|
|
6
7
|
/**
|
|
7
8
|
* api-fetch.ts — authenticated fetch helper for endpoints not yet in
|
|
@@ -61,6 +62,14 @@ class ApiFetchError extends Error {
|
|
|
61
62
|
* attributed by guesswork — see `node-adapter.ts`.
|
|
62
63
|
*/
|
|
63
64
|
requestId;
|
|
65
|
+
/**
|
|
66
|
+
* Set on a 401 when this CLI's own refresh was refused AND the local
|
|
67
|
+
* daemon, asked for a hand-off, refused too (OSK-12474). The daemon's reason
|
|
68
|
+
* decides the repair, so the 401 message carries it rather than a bare
|
|
69
|
+
* "Run `skrr login`" (OSK-12149, OSK-12151); `describeBrokerRefusal` turns
|
|
70
|
+
* it into words. Absent when the daemon was never asked.
|
|
71
|
+
*/
|
|
72
|
+
brokerFailure;
|
|
64
73
|
constructor(message, status, body, code, method, requestId) {
|
|
65
74
|
super(message);
|
|
66
75
|
this.name = 'ApiFetchError';
|
|
@@ -181,6 +190,7 @@ async function apiFetch(pathOrUrl, opts = {}) {
|
|
|
181
190
|
});
|
|
182
191
|
};
|
|
183
192
|
let res = await fetchWithCredential(cred);
|
|
193
|
+
let brokerFailure = null;
|
|
184
194
|
if (res.status === 401 && delegatedCli) {
|
|
185
195
|
// Deliberately terminal on broker failure: an Agent whose daemon cannot
|
|
186
196
|
// rotate its family must surface the 401, never fall through to
|
|
@@ -217,6 +227,25 @@ async function apiFetch(pathOrUrl, opts = {}) {
|
|
|
217
227
|
(0, node_adapter_1.setAdapterCredentialOverride)(cred);
|
|
218
228
|
res = await fetchWithCredential(cred);
|
|
219
229
|
}
|
|
230
|
+
else if (fresh.skippedReason === 'refresh_failed' ||
|
|
231
|
+
fresh.skippedReason === 'no_refresh_token') {
|
|
232
|
+
// OSK-12474 — this CLI's STORED sign-in could not be renewed (e.g.
|
|
233
|
+
// DEVICE_KEY_MISMATCH after a key split). The node-adapter asks the local
|
|
234
|
+
// daemon for a hand-off at this point; this transport did not, so a
|
|
235
|
+
// healthy daemon was ignored and every apiFetch command demanded
|
|
236
|
+
// `skrr login`. Gated on the stored-credential skip reasons only: a
|
|
237
|
+
// `--token`, OVERSKY_TOKEN, CI token or bare-mode credential is the
|
|
238
|
+
// operator's, and must never be swapped for the daemon's principal.
|
|
239
|
+
const brokered = await daemonHandoffAfterRefusedRefresh();
|
|
240
|
+
if (brokered.credential) {
|
|
241
|
+
cred = brokered.credential;
|
|
242
|
+
(0, node_adapter_1.setAdapterCredentialOverride)(cred);
|
|
243
|
+
res = await fetchWithCredential(cred);
|
|
244
|
+
}
|
|
245
|
+
else {
|
|
246
|
+
brokerFailure = brokered.failure;
|
|
247
|
+
}
|
|
248
|
+
}
|
|
220
249
|
}
|
|
221
250
|
if (!res.ok) {
|
|
222
251
|
let text = '';
|
|
@@ -235,7 +264,10 @@ async function apiFetch(pathOrUrl, opts = {}) {
|
|
|
235
264
|
catch {
|
|
236
265
|
/* ignore body read failures */
|
|
237
266
|
}
|
|
238
|
-
|
|
267
|
+
const error = new ApiFetchError(`HTTP ${res.status} ${res.statusText} — ${method} ${pathOrUrl}`, res.status, text, code, method, res.headers.get('x-request-id') || undefined);
|
|
268
|
+
if (brokerFailure)
|
|
269
|
+
error.brokerFailure = brokerFailure;
|
|
270
|
+
throw error;
|
|
239
271
|
}
|
|
240
272
|
// 204/205 carry no body by definition; an empty body has no content-type
|
|
241
273
|
// worth judging. Neither is an error.
|
|
@@ -255,6 +287,71 @@ async function apiFetch(pathOrUrl, opts = {}) {
|
|
|
255
287
|
}
|
|
256
288
|
return (await res.json());
|
|
257
289
|
}
|
|
290
|
+
/**
|
|
291
|
+
* One daemon hand-off attempt per process (OSK-12474). Memoized, including a
|
|
292
|
+
* failure: a command that makes many requests must not ask the daemon again
|
|
293
|
+
* on every 401, and a hand-off token that is itself refused is renewed by the
|
|
294
|
+
* brokered re-redeem branch above, never by coming back here — so this cannot
|
|
295
|
+
* loop.
|
|
296
|
+
*/
|
|
297
|
+
let daemonHandoffFallback = null;
|
|
298
|
+
function daemonHandoffAfterRefusedRefresh() {
|
|
299
|
+
daemonHandoffFallback ??= attemptDaemonHandoffFallback();
|
|
300
|
+
return daemonHandoffFallback;
|
|
301
|
+
}
|
|
302
|
+
async function attemptDaemonHandoffFallback() {
|
|
303
|
+
const cliConfig = (0, config_1.loadConfig)();
|
|
304
|
+
let result;
|
|
305
|
+
try {
|
|
306
|
+
// `maybeAutoBroker` applies the same opt-outs as the node-adapter's
|
|
307
|
+
// recovery: OVERSKY_TOKEN/OVERSKY_REFRESH_TOKEN and
|
|
308
|
+
// OVERSKY_SKIP_DAEMON_BROKER=1 leave it untriggered.
|
|
309
|
+
result = await (0, daemonBroker_1.maybeAutoBroker)({ cliConfig, bareMode: false });
|
|
310
|
+
}
|
|
311
|
+
catch {
|
|
312
|
+
return { credential: null, failure: null };
|
|
313
|
+
}
|
|
314
|
+
const outcome = result.outcome;
|
|
315
|
+
if (!result.triggered || !outcome)
|
|
316
|
+
return { credential: null, failure: null };
|
|
317
|
+
if (!outcome.ok)
|
|
318
|
+
return { credential: null, failure: outcome };
|
|
319
|
+
if (result.updatedConfig) {
|
|
320
|
+
try {
|
|
321
|
+
(0, config_1.saveConfig)(result.updatedConfig);
|
|
322
|
+
}
|
|
323
|
+
catch {
|
|
324
|
+
/* non-fatal: the next process mints a new cliId (see BaseCommand.init) */
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
const accessToken = outcome.accessToken;
|
|
328
|
+
// Where a durable mint was written; a brokered hand-off is never stored.
|
|
329
|
+
const storedSource = !outcome.brokered && outcome.storedIn === 'keychain' ? 'keychain' : 'file';
|
|
330
|
+
// Say where the credential now comes from, once. The adapter slot records a
|
|
331
|
+
// brokered token as `env-token` (for refresh semantics), which a reader would
|
|
332
|
+
// take for OVERSKY_TOKEN; the label must name the descriptor (OSK-12185).
|
|
333
|
+
const label = outcome.brokered
|
|
334
|
+
? (0, credential_resolver_1.describeCredentialSource)('env-token', {
|
|
335
|
+
kind: 'daemon-handoff',
|
|
336
|
+
...(outcome.descriptorPath ? { descriptorPath: outcome.descriptorPath } : {}),
|
|
337
|
+
})
|
|
338
|
+
: `a new sign-in brokered by the local daemon, saved to ${(0, credential_resolver_1.describeCredentialSource)(storedSource)}`;
|
|
339
|
+
process.stderr.write(`[skrr] This CLI's own sign-in could not be renewed; using ${label} instead.\n`);
|
|
340
|
+
return {
|
|
341
|
+
credential: {
|
|
342
|
+
token: accessToken,
|
|
343
|
+
// A brokered hand-off is held in memory for this process only; the
|
|
344
|
+
// durable mint was written to the backend the broker reported.
|
|
345
|
+
source: outcome.brokered ? 'env-token' : storedSource,
|
|
346
|
+
kind: (0, credential_resolver_1.kindOf)(accessToken),
|
|
347
|
+
},
|
|
348
|
+
failure: null,
|
|
349
|
+
};
|
|
350
|
+
}
|
|
351
|
+
/** @internal test seam — forget this process's daemon hand-off attempt. */
|
|
352
|
+
function __resetDaemonHandoffFallbackForTest() {
|
|
353
|
+
daemonHandoffFallback = null;
|
|
354
|
+
}
|
|
258
355
|
function trustedAutonomousBaseUrl() {
|
|
259
356
|
// Only the daemon-owned alias is accepted. OVERSKY_BASE_URL is a normal
|
|
260
357
|
// user/CLI override and may originate in request-carried provider env.
|
|
@@ -49,3 +49,24 @@ export interface BrokerRefusalDescription {
|
|
|
49
49
|
* (`base_url_mismatch`, `rate_limited`).
|
|
50
50
|
*/
|
|
51
51
|
export declare function describeBrokerRefusal(failure: DaemonBrokerFailure, bin?: string): BrokerRefusalDescription | null;
|
|
52
|
+
/**
|
|
53
|
+
* What to tell a person on a Dedicated Runtime guest when NO daemon answered
|
|
54
|
+
* the hand-off at all (OSK-12473).
|
|
55
|
+
*
|
|
56
|
+
* `describeBrokerRefusal` speaks only for a daemon that answered. On a guest
|
|
57
|
+
* the realistic broken state is a daemon that is absent: its credential was
|
|
58
|
+
* revoked and it exited by design, or systemd is cycling it, or it crashed and
|
|
59
|
+
* left its descriptor behind. The CLI then saw `no_bootstrap` / `network`, and
|
|
60
|
+
* fell to "Not signed in. Run `skrr login` first." — a login nobody performs
|
|
61
|
+
* on a guest, and which would only put a personal credential on a machine the
|
|
62
|
+
* daemon is meant to hold the account for. Say the machine's service is not
|
|
63
|
+
* running and name the owner's repair instead.
|
|
64
|
+
*
|
|
65
|
+
* Returns null off a guest, or for a daemon that answered (that is
|
|
66
|
+
* `describeBrokerRefusal`'s case).
|
|
67
|
+
*/
|
|
68
|
+
export declare function describeGuestDaemonUnavailable(failure: DaemonBrokerFailure | null, opts: {
|
|
69
|
+
bin?: string;
|
|
70
|
+
onGuest: boolean;
|
|
71
|
+
descriptorPresent?: boolean;
|
|
72
|
+
}): BrokerRefusalDescription | null;
|