@skrr-ai/cli 0.1.53 → 0.1.54

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.
@@ -30,10 +30,46 @@ export declare function approvalRow(a: Record<string, unknown>): {
30
30
  * on an older run that a later run got past is history, not the current state.
31
31
  */
32
32
  export declare function permissionBlockedRun(runs: Array<Record<string, unknown>>): Record<string, unknown> | null;
33
+ /** One refused call: the tool and its input, as the run recorded them. */
34
+ export type PermissionRefusal = {
35
+ tool: string | null;
36
+ input: string;
37
+ };
38
+ /** One way forward, as a command a person or a script can run. */
39
+ export type PermissionBlockRemedy = {
40
+ action: 'run_interactively' | 'allow_unattended' | 'restart';
41
+ command: string;
42
+ description: string;
43
+ };
44
+ /**
45
+ * The structured answer `--json` carries as `blockedRun` (OSK-12173). Before
46
+ * this the explanation existed only as prose, and with `--json` only on
47
+ * stderr, so a script reading the JSON saw `{"data":[]}` and nothing to act on.
48
+ */
49
+ export type BlockedRunSummary = {
50
+ runId: string;
51
+ failureCode: string;
52
+ /** The cause, from the run's own sentence, without its generic remedy. */
53
+ reason: string | null;
54
+ agentId: string | null;
55
+ /**
56
+ * The refused calls with their inputs. Empty when the run recorded none
57
+ * (runs finalized before the server kept them, or evidence without an input
58
+ * preview). Never inferred.
59
+ */
60
+ refused: PermissionRefusal[];
61
+ remedies: PermissionBlockRemedy[];
62
+ };
63
+ /**
64
+ * The block as data: which run, why, what was refused, and the ways forward
65
+ * with the agent id filled in (`<agent>` only when the run recorded no agent).
66
+ */
67
+ export declare function blockedRunSummary(run: Record<string, unknown>, bin?: string, taskRef?: string): BlockedRunSummary;
33
68
  /**
34
69
  * What to print for such a run: why it is blocked, what needed approval (the
35
- * run's own recorded sentence, which names the refused tool), that an
36
- * unattended run cannot prompt, and the two ways forward with exact commands.
70
+ * run's own sentence, plus each refused input it recorded), that an unattended
71
+ * run cannot prompt, and the ways forward, each stated once with the real
72
+ * agent id.
37
73
  */
38
74
  export declare function permissionBlockLines(run: Record<string, unknown>, bin?: string, taskRef?: string): string[];
39
75
  export default class TasksApprovalsList extends BaseCommand {
@@ -2,6 +2,7 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.approvalRow = approvalRow;
4
4
  exports.permissionBlockedRun = permissionBlockedRun;
5
+ exports.blockedRunSummary = blockedRunSummary;
5
6
  exports.permissionBlockLines = permissionBlockLines;
6
7
  const core_1 = require("@oclif/core");
7
8
  const data_provider_1 = require("@skrr-ai/data-provider");
@@ -46,28 +47,107 @@ function permissionBlockedRun(runs) {
46
47
  const code = latest.failureCode ?? latest.failureClass;
47
48
  return code === data_provider_1.PERMISSION_BLOCKED_FAILURE_CODE ? latest : null;
48
49
  }
50
+ /**
51
+ * Where the run's own fix begins in its recorded sentence.
52
+ *
53
+ * The server writes `failureReason` as the cause followed by the fix
54
+ * (`permissionBlockedRun.PERMISSION_BLOCK_GUIDANCE`), and that fix names the
55
+ * agent only as `<agent>` because the sentence is shared by every surface.
56
+ * This command knows the agent, so it states the fix itself, with the real id,
57
+ * and keeps only the cause from the sentence (OSK-12173): printing both showed
58
+ * the remedy twice, once with a placeholder.
59
+ */
60
+ const RECORDED_REMEDY_MARKER = 'Re-running will be refused';
61
+ function refusalsOf(run) {
62
+ const raw = run.permissionRefusals;
63
+ if (!Array.isArray(raw))
64
+ return [];
65
+ const out = [];
66
+ for (const entry of raw) {
67
+ if (!entry || typeof entry !== 'object')
68
+ continue;
69
+ const { tool, input } = entry;
70
+ if (typeof input !== 'string' || !input.trim())
71
+ continue;
72
+ out.push({ tool: typeof tool === 'string' && tool ? tool : null, input: input.trim() });
73
+ }
74
+ return out;
75
+ }
76
+ /** The cause, cut before the recorded remedy; `<agent>` filled in if any survives. */
77
+ function blockReason(run, agentId) {
78
+ const full = typeof run.failureReason === 'string' ? run.failureReason.trim() : '';
79
+ if (!full)
80
+ return null;
81
+ const at = full.indexOf(RECORDED_REMEDY_MARKER);
82
+ const cause = at > 0 ? full.slice(0, at).trim() : full;
83
+ return agentId ? cause.split('<agent>').join(agentId) : cause;
84
+ }
85
+ /**
86
+ * The block as data: which run, why, what was refused, and the ways forward
87
+ * with the agent id filled in (`<agent>` only when the run recorded no agent).
88
+ */
89
+ function blockedRunSummary(run, bin = 'skrr', taskRef = '<task-id>') {
90
+ const executor = run.executor;
91
+ const agentId = executor && typeof executor.agentId === 'string' && executor.agentId ? executor.agentId : null;
92
+ const agent = agentId ?? '<agent>';
93
+ const code = run.failureCode ?? run.failureClass;
94
+ return {
95
+ runId: String(run.runId ?? run.id ?? '-'),
96
+ failureCode: typeof code === 'string' ? code : data_provider_1.PERMISSION_BLOCKED_FAILURE_CODE,
97
+ reason: blockReason(run, agentId),
98
+ agentId,
99
+ refused: refusalsOf(run),
100
+ remedies: [
101
+ {
102
+ action: 'run_interactively',
103
+ command: `${bin} agents chat ${agent} --approve-tools --prompt "..."`,
104
+ description: 'Do the work interactively, approving each call.',
105
+ },
106
+ {
107
+ action: 'allow_unattended',
108
+ command: `${bin} agents update ${agent} --default-permission-mode bypassPermissions`,
109
+ description: 'Let unattended runs use the tool without asking.',
110
+ },
111
+ {
112
+ action: 'restart',
113
+ command: `${bin} tasks start ${taskRef}`,
114
+ description: 'Start the task again once its agent may use the tool (or pick an agent/harness whose mode allows it).',
115
+ },
116
+ ],
117
+ };
118
+ }
119
+ const SHELL_TOOL = /^(bash|shell|local_shell|exec_command|commandexecution|command_execution)$/i;
120
+ function toolLabel(tool) {
121
+ if (!tool)
122
+ return '';
123
+ return SHELL_TOOL.test(tool) ? ' (shell)' : ` (${tool})`;
124
+ }
49
125
  /**
50
126
  * What to print for such a run: why it is blocked, what needed approval (the
51
- * run's own recorded sentence, which names the refused tool), that an
52
- * unattended run cannot prompt, and the two ways forward with exact commands.
127
+ * run's own sentence, plus each refused input it recorded), that an unattended
128
+ * run cannot prompt, and the ways forward, each stated once with the real
129
+ * agent id.
53
130
  */
54
131
  function permissionBlockLines(run, bin = 'skrr', taskRef = '<task-id>') {
55
- const runId = String(run.runId ?? run.id ?? '-');
56
- const reason = typeof run.failureReason === 'string' ? run.failureReason.trim() : '';
57
- const executor = run.executor;
58
- const agent = executor && typeof executor.agentId === 'string' && executor.agentId
59
- ? executor.agentId
60
- : '<agent>';
132
+ const summary = blockedRunSummary(run, bin, taskRef);
133
+ const reason = summary.reason ?? '';
134
+ // The sentence already quotes a lone refused input (shortened); list an
135
+ // input only when the sentence does not already show it.
136
+ const refusedLines = summary.refused
137
+ .filter((refusal) => !reason.includes(refusal.input.slice(0, 60)))
138
+ .map((refusal) => ` Refused: ${refusal.input}${toolLabel(refusal.tool)}`);
139
+ const [chat, update, restart] = summary.remedies;
61
140
  return [
62
- `The latest run (${runId}) is blocked on a permission, not on an approval request.`,
141
+ `The latest run (${summary.runId}) is blocked on a permission, not on an approval request.`,
63
142
  ...(reason ? [` ${reason}`] : []),
143
+ ...refusedLines,
64
144
  'It ran unattended (`tasks start`), and an unattended run cannot prompt anyone to approve a',
65
145
  'tool call, so the ask was refused on the spot and no approval request was recorded here.',
66
146
  '',
67
147
  'To continue, either:',
68
- ` ${bin} agents chat ${agent} --approve-tools --prompt "..." do the work interactively, approving each call`,
69
- ` ${bin} agents update ${agent} --default-permission-mode bypassPermissions let unattended runs use the tool`,
70
- ` then: ${bin} tasks start ${taskRef} (or pick an agent/harness whose mode allows it)`,
148
+ ` ${chat.command} do the work interactively, approving each call`,
149
+ ` ${update.command} let unattended runs use the tool`,
150
+ ` then: ${restart.command} (or pick an agent/harness whose mode allows it)`,
71
151
  ];
72
152
  }
73
153
  class TasksApprovalsList extends base_command_1.BaseCommand {
@@ -98,25 +178,37 @@ class TasksApprovalsList extends base_command_1.BaseCommand {
98
178
  // row, so the empty list alone would say "nothing to act on" about a task
99
179
  // that is waiting on exactly that. Read the latest run, best-effort: the
100
180
  // approvals list is still the answer if the runs read fails.
181
+ // `runsRead` separates "the latest run is not blocked" from "we could not
182
+ // look": only the first may be reported to a script as `blockedRun: null`.
101
183
  let blockedRun = null;
184
+ let runsRead = false;
102
185
  try {
103
186
  const runs = (await data_provider_1.dataService.getTaskRuns(args.id));
104
187
  blockedRun = permissionBlockedRun(runs?.data ?? []);
188
+ runsRead = true;
105
189
  }
106
190
  catch {
107
191
  blockedRun = null;
108
192
  }
109
- const blockLines = blockedRun ? permissionBlockLines(blockedRun, this.config.bin, args.id) : [];
110
193
  if (flags.json) {
111
- // The server payload stays byte-identical; the pointer goes to stderr so
112
- // `--json` remains parseable and a script still learns the task is stuck.
113
- if (blockedRun) {
114
- this.warn(`The latest run is blocked on a permission an unattended run could not ask for; ` +
115
- `\`${this.config.bin} tasks approvals list ${args.id}\` (without --json) says what to do.`);
116
- }
117
- this.log(JSON.stringify(response, null, 2));
194
+ // OSK-12173 — the server payload is kept as it was (`data` unchanged)
195
+ // and the block is added beside it as data a script can act on:
196
+ // `blockedRun` is the summary, or null when the latest run is not
197
+ // blocked. It is omitted when the runs could not be read, because null
198
+ // would claim a state nobody checked.
199
+ const payload = response && typeof response === 'object' && !Array.isArray(response)
200
+ ? response
201
+ : { data: items };
202
+ const out = runsRead
203
+ ? {
204
+ ...payload,
205
+ blockedRun: blockedRun ? blockedRunSummary(blockedRun, this.config.bin, args.id) : null,
206
+ }
207
+ : payload;
208
+ this.log(JSON.stringify(out, null, 2));
118
209
  return;
119
210
  }
211
+ const blockLines = blockedRun ? permissionBlockLines(blockedRun, this.config.bin, args.id) : [];
120
212
  if (items.length === 0) {
121
213
  this.log('No approval requests.');
122
214
  if (blockLines.length > 0) {
@@ -329,14 +329,18 @@ async function consolidateWrappedDek(profile, kek, aad) {
329
329
  };
330
330
  // The paths go in the MESSAGE, not only the metadata: a log pipeline that
331
331
  // drops structured fields would otherwise render this as an unactionable
332
- // noun. Naming the remedy matters too — logging out on one side deletes that
333
- // side's key and lets the next start adopt the other.
332
+ // noun. Naming the remedy matters too, and it has to be one that converges
333
+ // (OSK-12172). Only the CLI's logout deletes a key, and only the canonical
334
+ // one; a daemon logout deletes neither. So the CLI logs out, the daemon
335
+ // BINARY restarts and adopts its legacy key into the empty canonical slot,
336
+ // and the CLI logs back in on it. Not `skrr daemon restart`: any `skrr`
337
+ // command mints a fresh key into an empty slot before doing anything else.
334
338
  (0, runtime_js_1.getAuthLogger)().warn(`[credEnvelope] profile "${profile}" has two different usable wrapped DEKs; ` +
335
339
  `refusing to choose. Keeping ${_pathConflict.legacyPath} and leaving ` +
336
340
  `${_pathConflict.canonicalPath} untouched. Each side can still read its own ` +
337
- "credentials, and neither can read the other's. To converge, log out on one " +
338
- `side (\`${(0, runtime_js_1.getAuthBinaryName)()} logout\` here) and log back in; the surviving key ` +
339
- 'is then adopted by both.');
341
+ "credentials, and neither can read the other's. Do not delete " +
342
+ `${_pathConflict.legacyPath}: it is the key the daemon reads. To converge on it, ` +
343
+ 'run `skrr logout && skrrd restart && skrr login`.');
340
344
  }
341
345
  /**
342
346
  * Initialize the envelope path. Idempotent — second call when state is
@@ -47,7 +47,7 @@ import lockfile from 'proper-lockfile';
47
47
  import { buildCredentialAad, decrypt as credEnvelopeDecrypt, deserialize as credEnvelopeDeserialize, encrypt as credEnvelopeEncrypt, isEnvelopeString as isCredEnvelopeString, generateDek, serialize as credEnvelopeSerialize, } from './credentialEnvelope.js';
48
48
  import { getKekStrategy, zeroizeKekCaches } from './kek/index.js';
49
49
  import { consolidateProfileState, profileStateDir, profileStateFilePathsToClear, resolveProfileStateFile, } from './profileStateDir.js';
50
- import { emitAuthTelemetry, getAuthBinaryName, getAuthLogger, getCredEnvelopeFailClosed, } from './runtime.js';
50
+ import { emitAuthTelemetry, getAuthLogger, getCredEnvelopeFailClosed } from './runtime.js';
51
51
  /**
52
52
  * Account name the wrapped DEK lives under (file fallback). Keeping the
53
53
  * literal here so a future rename surfaces as a single grep target.
@@ -309,14 +309,18 @@ async function consolidateWrappedDek(profile, kek, aad) {
309
309
  };
310
310
  // The paths go in the MESSAGE, not only the metadata: a log pipeline that
311
311
  // drops structured fields would otherwise render this as an unactionable
312
- // noun. Naming the remedy matters too — logging out on one side deletes that
313
- // side's key and lets the next start adopt the other.
312
+ // noun. Naming the remedy matters too, and it has to be one that converges
313
+ // (OSK-12172). Only the CLI's logout deletes a key, and only the canonical
314
+ // one; a daemon logout deletes neither. So the CLI logs out, the daemon
315
+ // BINARY restarts and adopts its legacy key into the empty canonical slot,
316
+ // and the CLI logs back in on it. Not `skrr daemon restart`: any `skrr`
317
+ // command mints a fresh key into an empty slot before doing anything else.
314
318
  getAuthLogger().warn(`[credEnvelope] profile "${profile}" has two different usable wrapped DEKs; ` +
315
319
  `refusing to choose. Keeping ${_pathConflict.legacyPath} and leaving ` +
316
320
  `${_pathConflict.canonicalPath} untouched. Each side can still read its own ` +
317
- "credentials, and neither can read the other's. To converge, log out on one " +
318
- `side (\`${getAuthBinaryName()} logout\` here) and log back in; the surviving key ` +
319
- 'is then adopted by both.');
321
+ "credentials, and neither can read the other's. Do not delete " +
322
+ `${_pathConflict.legacyPath}: it is the key the daemon reads. To converge on it, ` +
323
+ 'run `skrr logout && skrrd restart && skrr login`.');
320
324
  }
321
325
  /**
322
326
  * Initialize the envelope path. Idempotent — second call when state is
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skrr-ai/auth-core",
3
- "version": "0.4.6",
3
+ "version": "0.4.7",
4
4
  "main": "dist/cjs/index.js",
5
5
  "types": "dist/esm/index.d.ts",
6
6
  "exports": {