akm-cli 0.9.17-alpha.6 → 0.9.17-alpha.7

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 (38) hide show
  1. package/CHANGELOG.md +146 -0
  2. package/STABILITY.md +2 -2
  3. package/dist/akm +7 -7
  4. package/dist/assets/prompts/retrieval-relevance-judge.md +6 -0
  5. package/dist/commands/improve/consolidate.js +11 -0
  6. package/dist/commands/improve/improve-cli.js +27 -7
  7. package/dist/commands/improve/ledger.js +7 -3
  8. package/dist/commands/improve/preparation.js +40 -10
  9. package/dist/commands/improve/reflect.js +46 -22
  10. package/dist/commands/improve/retrieval-gate.js +127 -0
  11. package/dist/commands/improve/retrieval-scope.js +77 -0
  12. package/dist/commands/read/curate.js +1 -17
  13. package/dist/commands/tasks/tasks-cli.js +10 -12
  14. package/dist/commands/tasks/tasks.js +57 -56
  15. package/dist/commands/tasks/validate.js +27 -46
  16. package/dist/core/adapter/adapters/akm-task-adapter.js +29 -8
  17. package/dist/core/improve-result.js +4 -1
  18. package/dist/core/non-task-input.js +20 -0
  19. package/dist/core/paths.js +0 -4
  20. package/dist/indexer/indexer.js +1 -3
  21. package/dist/indexer/usage/usage-events.js +34 -0
  22. package/dist/scripts/akm-migrate-node.js +5822 -5833
  23. package/dist/scripts/akm-migrate.js +6301 -6312
  24. package/dist/storage/repositories/proposals-repository.js +4 -0
  25. package/dist/tasks/backends/cron.js +80 -43
  26. package/dist/tasks/backends/launchd.js +28 -15
  27. package/dist/tasks/backends/schtasks.js +25 -10
  28. package/dist/tasks/run/load-task.js +1 -1
  29. package/dist/tasks/scheduler-binding.js +4 -2
  30. package/dist/tasks/scheduler-invocation.js +127 -235
  31. package/dist/tasks/scheduler-sync.js +13 -8
  32. package/dist/tasks/source/parse-task-source.js +22 -126
  33. package/dist/tasks/source/task-to-v3.js +1 -55
  34. package/dist/tasks/source/task-to-v4.js +1 -13
  35. package/docs/migration/v0.9.1-to-v0.9.2.md +7 -3
  36. package/docs/reference/cli.md +4 -3
  37. package/docs/reference/tasks.md +58 -40
  38. package/package.json +1 -1
@@ -385,6 +385,10 @@ export function getStateProposal(db, id, stashDir) {
385
385
  const row = (stashDir ? db.prepare(sql).get(id, stashDir) : db.prepare(sql).get(id));
386
386
  return row ? proposalRowToProposal(row) : undefined;
387
387
  }
388
+ /** `(ref, source)` of every proposal, any status, in one stash: no payload is read. */
389
+ export function listProposalRefSources(db, stashDir) {
390
+ return db.prepare("SELECT ref, source FROM proposals WHERE stash_dir = ?").all(stashDir);
391
+ }
388
392
  /**
389
393
  * Find PENDING proposal ids in one stash whose id starts with `idPrefix`.
390
394
  * Backs the UUID-prefix form of `akm proposal show/accept/... <prefix>` —
@@ -7,9 +7,14 @@
7
7
  // its other lines untouched:
8
8
  //
9
9
  // # akm:task <id> BEGIN
10
- // [SCHED] /abs/akm --scheduler-context <descriptor> task run <id> ... > <log> 2>&1
10
+ // [SCHED] AKM_BUNDLE_DIR=<working stash> /abs/akm task run <id> ... > <log> 2>&1
11
11
  // # akm:task <id> END
12
12
  //
13
+ // The row sets its own environment inline, as a `VAR=value` prefix (see
14
+ // `src/tasks/scheduler-invocation.ts`); PATH is the `# akm:env` block. A
15
+ // command over the portable line limit runs `sh <wrapper script>` instead,
16
+ // and the script holds the same environment and argv.
17
+ //
13
18
  // The backend reads/writes the user's crontab via `crontab -l` and
14
19
  // `crontab -`. Every mutation is one read → modify the akm blocks (and the
15
20
  // `# akm:env` PATH header) → one write, with the prior crontab restored when
@@ -32,13 +37,14 @@
32
37
  // Tests inject a fake exec so unit tests don't touch the real crontab.
33
38
  import { spawnSync } from "node:child_process";
34
39
  import { createHash } from "node:crypto";
40
+ import fs from "node:fs";
35
41
  import path from "node:path";
36
42
  import { ConfigError } from "../../core/errors.js";
37
43
  import { getTaskLogDir } from "../../core/paths.js";
38
44
  import { resolveAkmInvocation } from "../resolve-akm-bin.js";
39
45
  import { parseSchedule, translateToCron } from "../schedule.js";
40
46
  import { schedulerBindingNativeId, schedulerLogicalBindingId } from "../scheduler-binding.js";
41
- import { buildScheduledBindingInvocation, parsePublicSchedulerInvocation, parseScheduledBindingArgv, resolveScheduledTaskContext, SCHEDULER_CONTEXT_ARG, schedulerContextDescriptor, schedulerContextPath, } from "../scheduler-invocation.js";
47
+ import { buildScheduledInvocation, parseScheduledInvocationArgv, scheduledRowEnvironmentEntries, scheduledRowEnvironmentFrom, } from "../scheduler-invocation.js";
42
48
  import { nodeFs, throwIfNotOk } from "./exec-utils.js";
43
49
  const BEGIN = (id) => `# akm:task ${assertCronValue(id)} BEGIN`;
44
50
  const END = (id) => `# akm:task ${assertCronValue(id)} END`;
@@ -55,9 +61,8 @@ export function CRON_BACKEND(options = {}) {
55
61
  const logDir = options.logDir ?? getTaskLogDir();
56
62
  const akmArgv = options.akmArgv ?? resolveAkmInvocation().argv;
57
63
  const envPath = options.envPath === false ? undefined : (options.envPath ?? process.env.PATH);
58
- const scheduledContext = options.scheduledContext ?? resolveScheduledTaskContext();
59
- const defaultContextPath = schedulerContextPath(schedulerContextDescriptor(scheduledContext));
60
- const lineFor = (task, opts) => buildCronLineParts(task, [...(opts?.binding ?? akmArgv)], logDir, opts?.contextPath ?? defaultContextPath);
64
+ const readFile = options.fs?.readFile ?? ((file) => fs.readFileSync(file, "utf8"));
65
+ const lineFor = (task, opts) => buildCronLineParts(task, [...(opts?.binding ?? akmArgv)], logDir, opts?.environment);
61
66
  return {
62
67
  name: "cron",
63
68
  install(task, opts) {
@@ -92,7 +97,7 @@ export function CRON_BACKEND(options = {}) {
92
97
  list() {
93
98
  const rows = [];
94
99
  for (const { id, body } of listBlocks(readCrontab(exec))) {
95
- const parsed = extractCronInvocation(body);
100
+ const parsed = extractCronInvocation(body) ?? extractCronWrapperInvocation(body, readFile);
96
101
  if (!parsed)
97
102
  continue;
98
103
  rows.push({
@@ -102,9 +107,8 @@ export function CRON_BACKEND(options = {}) {
102
107
  signature: normalizeSignature(body),
103
108
  ...(parsed.target !== undefined ? { target: parsed.target } : {}),
104
109
  binding: parsed.binding,
105
- // A pre-0.9.2 row has no descriptor of its own (#881); it reads as
106
- // the current one so ownership resolves and sync rewrites it.
107
- contextPath: parsed.contextPath || defaultContextPath,
110
+ ...(parsed.contextPath !== undefined ? { contextPath: parsed.contextPath } : {}),
111
+ ...(parsed.environment !== undefined ? { environment: parsed.environment } : {}),
108
112
  invocation: parsed.invocation,
109
113
  });
110
114
  }
@@ -117,21 +121,26 @@ export function CRON_BACKEND(options = {}) {
117
121
  },
118
122
  };
119
123
  }
120
- function buildCronLineParts(task, akmArgv, logDir, contextPath) {
124
+ function buildCronLineParts(task, akmArgv, logDir, environment) {
121
125
  const cronExpr = translateToCron(parseSchedule(task.cron, "cron"));
122
126
  const nativeId = schedulerBindingNativeId(task);
123
127
  const logPath = path.join(logDir, `${nativeId}.log`);
124
- const invocation = buildScheduledBindingInvocation(akmArgv, contextPath, task.invocation);
125
- const cmd = invocation.argv.map((part) => quoteForCron(part)).join(" ");
128
+ const argv = buildScheduledInvocation(akmArgv, task.invocation);
129
+ const variables = scheduledRowEnvironmentEntries(environment);
130
+ // An assignment prefix: the shell cron hands the line to sets it for this
131
+ // command only. Only the value is quoted, or the word is not an assignment.
132
+ const assignments = variables.map(([name, value]) => `${name}=${quoteForCron(value)} `).join("");
133
+ const cmd = argv.map((part) => quoteForCron(part)).join(" ");
126
134
  // #951: truncate (not append) so this bootstrap safety-net file always
127
135
  // holds exactly the latest run's raw output. akm's own per-run log
128
136
  // (src/tasks/run/task-log.ts) keeps history.
129
- const directLine = `${cronExpr} ${cmd} > ${quoteForCron(logPath)} 2>&1`;
137
+ const directLine = `${cronExpr} ${assignments}${cmd} > ${quoteForCron(logPath)} 2>&1`;
130
138
  if (Buffer.byteLength(directLine, "utf8") <= PORTABLE_CRON_LINE_LIMIT)
131
139
  return { line: directLine };
132
140
  // A command over vixie-cron's MAX_COMMAND is spilled into a short wrapper
133
141
  // script under logDir instead of being truncated or refused.
134
- const content = `#!/bin/sh\nexec ${invocation.argv.map(quoteForShellScript).join(" ")}\n`;
142
+ const exports = variables.map(([name, value]) => `export ${name}=${quoteForShellScript(value)}\n`).join("");
143
+ const content = `#!/bin/sh\n${exports}exec ${argv.map(quoteForShellScript).join(" ")}\n`;
135
144
  const contentHash = createHash("sha256").update(content).digest("hex").slice(0, 16);
136
145
  const wrapperPath = path.join(logDir, `${CRON_WRAPPER_PREFIX}${nativeId}-${contentHash}.sh`);
137
146
  const line = `${cronExpr} sh ${quoteForCron(wrapperPath)} > ${quoteForCron(logPath)} 2>&1`;
@@ -142,8 +151,8 @@ function quoteForShellScript(part) {
142
151
  return part;
143
152
  return `'${part.replace(/'/g, `'\\''`)}'`;
144
153
  }
145
- export function buildCronLine(task, akmArgv, logDir, contextPath) {
146
- return buildCronLineParts(task, akmArgv, logDir, contextPath).line;
154
+ export function buildCronLine(task, akmArgv, logDir, environment) {
155
+ return buildCronLineParts(task, akmArgv, logDir, environment).line;
147
156
  }
148
157
  /** The crontab line as it appears inside a block — commented when disabled. */
149
158
  export function cronBlockBody(cronLine, enabled) {
@@ -196,46 +205,74 @@ function malformedBlockError(id) {
196
205
  export function extractInstalledTarget(body) {
197
206
  return extractCronInvocation(body)?.target;
198
207
  }
208
+ /**
209
+ * Parse an installed cron body: its inline `VAR=value` environment, its
210
+ * launcher, and its public tail — a current row, a row naming a
211
+ * `--scheduler-context` descriptor (0.9.0 – 0.9.17-alpha.6), or an older
212
+ * row with neither (#881). This only ever runs on a body already isolated
213
+ * between akm's own BEGIN/END markers, so no trust extends to unmarked lines.
214
+ */
199
215
  export function extractCronInvocation(body) {
200
216
  const line = body.startsWith(DISABLED_PREFIX) ? body.slice(DISABLED_PREFIX.length) : body;
201
217
  const fields = splitCronShellWords(line);
202
218
  if (fields.length < 6)
203
219
  return undefined;
204
220
  let commandStart = 5;
205
- while (commandStart < fields.length && /^[A-Za-z_][A-Za-z0-9_]*=/.test(fields[commandStart]))
221
+ const variables = {};
222
+ for (let field = fields[commandStart]; field !== undefined; field = fields[commandStart]) {
223
+ const assignment = /^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/s.exec(field);
224
+ if (!assignment)
225
+ break;
226
+ variables[assignment[1]] = assignment[2];
206
227
  commandStart += 1;
228
+ }
207
229
  // #951: new rows redirect with `>`; rows written before that use `>>`.
208
230
  const redirectIndex = fields.findIndex((field, index) => index >= commandStart && (field === ">" || field === ">>"));
209
231
  if (redirectIndex === -1)
210
232
  return undefined;
211
- const tail = fields.slice(commandStart, redirectIndex);
212
- const parsed = parseScheduledBindingArgv(tail);
213
- if (parsed)
214
- return parsed;
215
- // Rows written by akm < 0.9.2 have no `--scheduler-context` argument at
216
- // all — the akm argv is followed directly by the public `task run …` /
217
- // `workflow run …` tail (#881). This only ever runs on a body already
218
- // isolated between akm's own BEGIN/END markers, so recognizing the older
219
- // shape extends no trust to unmarked crontab lines. A row that DOES carry
220
- // the marker but fails to parse is never reinterpreted as legacy.
221
- if (tail.includes(SCHEDULER_CONTEXT_ARG))
233
+ const parsed = parseScheduledInvocationArgv(fields.slice(commandStart, redirectIndex));
234
+ if (!parsed)
222
235
  return undefined;
223
- for (let index = 0; index < tail.length - 1; index += 1) {
224
- if ((tail[index] === "task" || tail[index] === "workflow") && tail[index + 1] === "run") {
225
- const publicInvocation = parsePublicSchedulerInvocation(tail.slice(index));
226
- if (!publicInvocation)
227
- return undefined;
228
- return {
229
- binding: tail.slice(0, index),
230
- contextPath: "",
231
- invocation: publicInvocation.invocation,
232
- ...(publicInvocation.target !== undefined ? { target: publicInvocation.target } : {}),
233
- };
234
- }
236
+ const environment = scheduledRowEnvironmentFrom(variables);
237
+ return environment ? { ...parsed, environment } : parsed;
238
+ }
239
+ /**
240
+ * Parse a row spilled into a wrapper script (`sh <script> > <log> 2>&1`):
241
+ * the script's `export NAME=value` lines and its `exec` argv. Only akm's own
242
+ * wrapper scripts are read; one that is gone or does not parse leaves the
243
+ * row unlisted, as any unparsable row is.
244
+ */
245
+ export function extractCronWrapperInvocation(body, readFile) {
246
+ const line = body.startsWith(DISABLED_PREFIX) ? body.slice(DISABLED_PREFIX.length) : body;
247
+ const fields = splitCronShellWords(line);
248
+ const script = fields[6];
249
+ if (fields[5] !== "sh" || !script || !path.basename(script).startsWith(CRON_WRAPPER_PREFIX))
250
+ return undefined;
251
+ if (fields[7] !== ">" && fields[7] !== ">>")
252
+ return undefined;
253
+ let content;
254
+ try {
255
+ content = readFile(script);
235
256
  }
236
- return undefined;
257
+ catch {
258
+ return undefined;
259
+ }
260
+ const variables = {};
261
+ let parsed;
262
+ for (const scriptLine of content.split("\n")) {
263
+ const words = splitCronShellWords(scriptLine);
264
+ const assignment = words[0] === "export" ? /^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/s.exec(words[1] ?? "") : null;
265
+ if (assignment)
266
+ variables[assignment[1]] = assignment[2];
267
+ else if (words[0] === "exec")
268
+ parsed = parseScheduledInvocationArgv(words.slice(1));
269
+ }
270
+ if (!parsed)
271
+ return undefined;
272
+ const environment = scheduledRowEnvironmentFrom(variables);
273
+ return environment ? { ...parsed, environment } : parsed;
237
274
  }
238
- /** Reverse {@link quoteForCron} for a single whitespace-free token. */
275
+ /** Reverse {@link quoteForCron}: sh word splitting, where a backslash is literal inside single quotes. */
239
276
  function splitCronShellWords(value) {
240
277
  const words = [];
241
278
  let current = "";
@@ -246,7 +283,7 @@ function splitCronShellWords(value) {
246
283
  quoted = !quoted;
247
284
  continue;
248
285
  }
249
- if (char === "\\" && index + 1 < value.length) {
286
+ if (!quoted && char === "\\" && index + 1 < value.length) {
250
287
  current += value[index + 1];
251
288
  index += 1;
252
289
  continue;
@@ -17,7 +17,9 @@
17
17
  * when the user is logged out should be installed as system Daemons,
18
18
  * which is out of scope.
19
19
  * • launchd strips the environment; the syncing shell's PATH goes into the
20
- * plist's `EnvironmentVariables` so task bodies find the same binaries.
20
+ * plist's `EnvironmentVariables` so task bodies find the same binaries,
21
+ * next to the AKM directory environment the row sets (see
22
+ * `src/tasks/scheduler-invocation.ts`).
21
23
  *
22
24
  * Tests inject a fake exec + filesystem so the backend can be unit-tested
23
25
  * without touching the host launchctl.
@@ -33,7 +35,7 @@ import { warn } from "../../core/warn.js";
33
35
  import { resolveAkmInvocation } from "../resolve-akm-bin.js";
34
36
  import { parseSchedule, translateToLaunchd } from "../schedule.js";
35
37
  import { schedulerBindingNativeId, schedulerLogicalBindingId } from "../scheduler-binding.js";
36
- import { buildScheduledBindingInvocation, parseScheduledBindingArgv, resolveScheduledTaskContext, schedulerContextDescriptor, schedulerContextPath, } from "../scheduler-invocation.js";
38
+ import { buildScheduledInvocation, parseScheduledInvocationArgv, scheduledRowEnvironmentEntries, scheduledRowEnvironmentFrom, } from "../scheduler-invocation.js";
37
39
  import { escapeXml, nodeExec, nodeFs, runOrThrow } from "./exec-utils.js";
38
40
  export const LAUNCHD_LABEL_PREFIX = "com.akm.task.";
39
41
  const LAUNCHD_AKM_LABEL_RE = /^com\.akm\.task\.[A-Za-z0-9._-]{1,1024}$/u;
@@ -43,9 +45,7 @@ export function LAUNCHD_BACKEND(options = {}) {
43
45
  const agentsDir = options.agentsDir ?? defaultAgentsDir();
44
46
  const logDir = options.logDir ?? getTaskLogDir();
45
47
  const akmArgv = options.akmArgv ?? resolveAkmInvocation().argv;
46
- const scheduledContext = options.scheduledContext ?? resolveScheduledTaskContext();
47
48
  const envPath = options.envPath === false ? undefined : typeof options.envPath === "string" ? options.envPath : process.env.PATH;
48
- const defaultContextPath = schedulerContextPath(schedulerContextDescriptor(scheduledContext));
49
49
  const plistPath = (nativeId) => path.join(agentsDir, `${LAUNCHD_LABEL_PREFIX}${nativeId}.plist`);
50
50
  const label = (nativeId) => `${LAUNCHD_LABEL_PREFIX}${nativeId}`;
51
51
  const target = (nativeId) => `gui/${exec.uid()}/${label(nativeId)}`;
@@ -59,7 +59,7 @@ export function LAUNCHD_BACKEND(options = {}) {
59
59
  isOk: (r) => r.status === 0 || isServiceNotFoundResult(r),
60
60
  message: (r) => `launchctl bootout failed (exit ${r.status}): ${r.stderr || r.stdout || "no output"}.`,
61
61
  });
62
- const xmlFor = (task, opts) => buildPlistXml(task, [...(opts?.binding ?? akmArgv)], logDir, opts?.contextPath ?? defaultContextPath, envPath);
62
+ const xmlFor = (task, opts) => buildPlistXml(task, [...(opts?.binding ?? akmArgv)], logDir, opts?.environment, envPath);
63
63
  return {
64
64
  name: "launchd",
65
65
  install(task, opts) {
@@ -130,7 +130,8 @@ export function LAUNCHD_BACKEND(options = {}) {
130
130
  signature: launchdFingerprint(raw, enabled, loaded),
131
131
  ...(parsed.target !== undefined ? { target: parsed.target } : {}),
132
132
  binding: parsed.binding,
133
- contextPath: parsed.contextPath,
133
+ ...(parsed.contextPath !== undefined ? { contextPath: parsed.contextPath } : {}),
134
+ ...(parsed.environment !== undefined ? { environment: parsed.environment } : {}),
134
135
  invocation: parsed.invocation,
135
136
  });
136
137
  }
@@ -150,7 +151,16 @@ export function extractPlistInvocation(xml) {
150
151
  if (!block)
151
152
  return undefined;
152
153
  const args = [...block[1].matchAll(/<string>([\s\S]*?)<\/string>/g)].map((m) => decodeXmlEntities(m[1]));
153
- return parseScheduledBindingArgv(args);
154
+ const parsed = parseScheduledInvocationArgv(args);
155
+ if (!parsed)
156
+ return undefined;
157
+ const variables = {};
158
+ const dict = xml.match(/<key>EnvironmentVariables<\/key>\s*<dict>([\s\S]*?)<\/dict>/)?.[1] ?? "";
159
+ for (const entry of dict.matchAll(/<key>([^<]*)<\/key>\s*<string>([\s\S]*?)<\/string>/g)) {
160
+ variables[decodeXmlEntities(entry[1])] = decodeXmlEntities(entry[2]);
161
+ }
162
+ const environment = scheduledRowEnvironmentFrom(variables);
163
+ return environment ? { ...parsed, environment } : parsed;
154
164
  }
155
165
  function decodeXmlEntities(value) {
156
166
  return value
@@ -161,22 +171,25 @@ function decodeXmlEntities(value) {
161
171
  .replaceAll("&amp;", "&");
162
172
  }
163
173
  // ── XML builder (exported for tests) ────────────────────────────────────────
164
- function renderPlistEnvironment(envPath) {
165
- if (!envPath)
174
+ function renderPlistEnvironment(envPath, environment) {
175
+ const variables = [
176
+ ...(envPath ? [["PATH", envPath]] : []),
177
+ ...scheduledRowEnvironmentEntries(environment),
178
+ ];
179
+ if (variables.length === 0)
166
180
  return "";
167
181
  return [
168
182
  " <key>EnvironmentVariables</key>",
169
183
  " <dict>",
170
- " <key>PATH</key>",
171
- ` <string>${escapeXml(envPath)}</string>`,
184
+ ...variables.flatMap(([name, value]) => [` <key>${name}</key>`, ` <string>${escapeXml(value)}</string>`]),
172
185
  " </dict>",
173
186
  "",
174
187
  ].join("\n");
175
188
  }
176
- export function buildPlistXml(task, akmArgv, logDir, contextPath, envPath) {
189
+ export function buildPlistXml(task, akmArgv, logDir, environment, envPath) {
177
190
  const trigger = translateToLaunchd(parseSchedule(task.cron, "launchd"));
178
- const invocation = buildScheduledBindingInvocation(akmArgv, contextPath, task.invocation);
179
- const programArgs = invocation.argv.map((a) => ` <string>${escapeXml(a)}</string>`).join("\n");
191
+ const argv = buildScheduledInvocation(akmArgv, task.invocation);
192
+ const programArgs = argv.map((a) => ` <string>${escapeXml(a)}</string>`).join("\n");
180
193
  const nativeId = schedulerBindingNativeId(task);
181
194
  const logPath = path.join(logDir, `${nativeId}.log`);
182
195
  const xml = launchdTemplate
@@ -184,7 +197,7 @@ export function buildPlistXml(task, akmArgv, logDir, contextPath, envPath) {
184
197
  .replace("{{LABEL}}", LAUNCHD_LABEL_PREFIX + escapeXml(nativeId))
185
198
  .replace("{{PROGRAM_ARGS}}", programArgs)
186
199
  .replaceAll("{{LOG_PATH}}", escapeXml(logPath))
187
- .replace("{{ENV_VARS}}", renderPlistEnvironment(envPath))
200
+ .replace("{{ENV_VARS}}", () => renderPlistEnvironment(envPath, environment))
188
201
  .replace("{{TRIGGER_XML}}", renderLaunchdTrigger(trigger));
189
202
  for (const char of xml) {
190
203
  const code = char.codePointAt(0) ?? 0;
@@ -23,7 +23,11 @@
23
23
  * `TaskName,Next Run Time,Status` — so the regex anchors on the task
24
24
  * name as the leading quoted field.
25
25
  * • Task Scheduler runs a task with the account's own environment, so
26
- * PATH is not carried anywhere; the descriptor holds directories only.
26
+ * PATH is not carried anywhere. The Exec action has no environment of
27
+ * its own: the AKM directory environment a row sets (see
28
+ * `src/tasks/scheduler-invocation.ts`) is a `$env:NAME='value';`
29
+ * assignment ahead of the call operator in the PowerShell command,
30
+ * inherited by the akm process it starts.
27
31
  *
28
32
  * Tests inject a fake exec + filesystem.
29
33
  */
@@ -37,7 +41,7 @@ import { getTaskLogDir } from "../../core/paths.js";
37
41
  import { resolveAkmInvocation } from "../resolve-akm-bin.js";
38
42
  import { parseSchedule, translateToSchtasks } from "../schedule.js";
39
43
  import { schedulerBindingNativeId, schedulerLogicalBindingId } from "../scheduler-binding.js";
40
- import { buildScheduledBindingInvocation, parseScheduledBindingArgv, resolveScheduledTaskContext, schedulerContextDescriptor, schedulerContextPath, } from "../scheduler-invocation.js";
44
+ import { buildScheduledInvocation, parseScheduledInvocationArgv, scheduledRowEnvironmentEntries, scheduledRowEnvironmentFrom, } from "../scheduler-invocation.js";
41
45
  import { escapeXml, nodeExec, nodeFs, normalizeXmlForUtf16File, runOrThrow, } from "./exec-utils.js";
42
46
  export const DEFAULT_FOLDER_PREFIX = "\\akm\\";
43
47
  const SIGNATURE_PREFIX = "akm:v1:";
@@ -47,15 +51,13 @@ export function SCHTASKS_BACKEND(options = {}) {
47
51
  const akmArgv = options.akmArgv ?? resolveAkmInvocation().argv;
48
52
  const logDir = options.logDir ?? getTaskLogDir();
49
53
  const folder = options.folderPrefix ?? DEFAULT_FOLDER_PREFIX;
50
- const scheduledContext = options.scheduledContext ?? resolveScheduledTaskContext();
51
- const defaultContextPath = schedulerContextPath(schedulerContextDescriptor(scheduledContext));
52
54
  const userSid = options.userSid ?? resolveCurrentUserSid(exec);
53
55
  const taskName = (nativeId) => `${folder}${nativeId}`;
54
56
  const xmlFor = (task, opts) => buildSchtasksXml(task, akmArgv, logDir, {
55
57
  folderPrefix: folder,
56
- contextPath: opts?.contextPath ?? defaultContextPath,
57
58
  userSid,
58
59
  binding: [...(opts?.binding ?? akmArgv)],
60
+ ...(opts?.environment ? { environment: opts.environment } : {}),
59
61
  });
60
62
  const queryXml = (nativeId) => runOrThrow(exec, ["schtasks", "/Query", "/TN", taskName(nativeId), "/XML"], {
61
63
  message: (result) => `schtasks /Query /XML for "${taskName(nativeId)}" failed (exit ${result.status}): ${result.stderr || result.stdout || "no output"}.`,
@@ -116,7 +118,8 @@ export function SCHTASKS_BACKEND(options = {}) {
116
118
  signature: taskXmlSignature(xml),
117
119
  ...(parsed.target !== undefined ? { target: parsed.target } : {}),
118
120
  binding: parsed.binding,
119
- contextPath: parsed.contextPath,
121
+ ...(parsed.contextPath !== undefined ? { contextPath: parsed.contextPath } : {}),
122
+ ...(parsed.environment !== undefined ? { environment: parsed.environment } : {}),
120
123
  invocation: parsed.invocation,
121
124
  });
122
125
  }
@@ -139,7 +142,16 @@ export function extractSchtasksInvocation(xml) {
139
142
  const invocationStart = findPowerShellInvocationOperator(commandLine);
140
143
  if (invocationStart === undefined)
141
144
  return undefined;
142
- return parseScheduledBindingArgv(parsePowerShellSingleQuotedArgs(commandLine, invocationStart + 1));
145
+ const parsed = parseScheduledInvocationArgv(parsePowerShellSingleQuotedArgs(commandLine, invocationStart + 1));
146
+ if (!parsed)
147
+ return undefined;
148
+ const variables = {};
149
+ const assignments = commandLine.slice(0, invocationStart);
150
+ for (const assignment of assignments.matchAll(/\$env:([A-Za-z_][A-Za-z0-9_]*)='((?:[^']|'')*)'\s*;/g)) {
151
+ variables[assignment[1]] = assignment[2].replaceAll("''", "'");
152
+ }
153
+ const environment = scheduledRowEnvironmentFrom(variables);
154
+ return environment ? { ...parsed, environment } : parsed;
143
155
  }
144
156
  function findPowerShellInvocationOperator(script) {
145
157
  let inSingleQuote = false;
@@ -196,8 +208,11 @@ export function buildSchtasksXml(task, akmArgv, logDir, options) {
196
208
  const folder = options.folderPrefix ?? DEFAULT_FOLDER_PREFIX;
197
209
  const now = options.now ? options.now() : new Date();
198
210
  const trigger = translateToSchtasks(parseSchedule(task.cron, "schtasks"));
199
- const invocation = buildScheduledBindingInvocation(options.binding ?? akmArgv, options.contextPath, task.invocation);
200
- const script = `& ${invocation.argv.map((arg) => quotePowerShell(arg)).join(" ")}; exit $LASTEXITCODE`;
211
+ const argv = buildScheduledInvocation(options.binding ?? akmArgv, task.invocation);
212
+ const assignments = scheduledRowEnvironmentEntries(options.environment)
213
+ .map(([name, value]) => `$env:${name}=${quotePowerShell(value)}; `)
214
+ .join("");
215
+ const script = `${assignments}& ${argv.map((arg) => quotePowerShell(arg)).join(" ")}; exit $LASTEXITCODE`;
201
216
  const command = "powershell.exe";
202
217
  const args = ["-NoLogo", "-NoProfile", "-NonInteractive", "-Command", script].map(quoteArg).join(" ");
203
218
  const nativeId = schedulerBindingNativeId(task);
@@ -216,7 +231,7 @@ export function buildSchtasksXml(task, akmArgv, logDir, options) {
216
231
  .replace('<Principal id="Author">', `<Principal id="Author">\n <UserId>${escapeXml(options.userSid)}</UserId>`)
217
232
  .replace("{{ENABLED}}", task.enabled ? "true" : "false")
218
233
  .replace("{{COMMAND}}", escapeXml(command))
219
- .replace("{{ARGS}}", escapeXml(args))
234
+ .replace("{{ARGS}}", () => escapeXml(args))
220
235
  .replace("{{LOG_PATH}}", escapeXml(logPath));
221
236
  }
222
237
  function renderSchtasksTrigger(trigger, now) {
@@ -100,7 +100,7 @@ export async function loadPreparedTask(id, options) {
100
100
  bundleRoot: bundleDir,
101
101
  config,
102
102
  // Agent profiles build child env from an allowlist, so freeze the closed
103
- // scheduler-restored AKM directory context before command preparation.
103
+ // AKM directory context the scheduled row set before command preparation.
104
104
  ...(options.scheduled ? { schedulerContext: scheduledTaskContextEnv() } : {}),
105
105
  resolveAsset: async ({ bundle, type, name }) => {
106
106
  if (bundle === bundleName) {
@@ -6,8 +6,10 @@
6
6
  * contract itself.
7
7
  *
8
8
  * A binding carries only stable source identity, trigger identity, and the
9
- * public CLI tail. Source content, action inputs, environment values, and
10
- * credentials never cross this boundary or reach an OS scheduler.
9
+ * public CLI tail; its install options add the launcher and the AKM
10
+ * directory environment the row sets inline. Source content, action inputs,
11
+ * task environment values, and credentials never cross this boundary or
12
+ * reach an OS scheduler.
11
13
  */
12
14
  import { createHash } from "node:crypto";
13
15
  import { bundleRefToString, parseBundleRef } from "../core/asset/asset-ref.js";