@kici-dev/agent 0.1.16 → 0.1.17

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/index.js CHANGED
@@ -5,7 +5,7 @@ import { z } from "zod";
5
5
  import { LOGGER_ENV_VARS, defineEnv, validateUnknownKiciVars } from "@kici-dev/shared/env";
6
6
  import { KNOWN_ROLES, validateNoReservedLabels } from "@kici-dev/engine";
7
7
  import { execFile } from "node:child_process";
8
- import { access, cp, mkdir, mkdtemp, readFile, readdir, rename, rm, unlink, writeFile } from "node:fs/promises";
8
+ import { access, cp, lstat, mkdir, mkdtemp, readFile, readdir, realpath, rename, rm, unlink, writeFile } from "node:fs/promises";
9
9
  import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
10
10
  import { promisify } from "node:util";
11
11
  import { createLogger, sha256, toErrorMessage } from "@kici-dev/shared";
@@ -309,6 +309,11 @@ function redactNpmOutput(input, tokens) {
309
309
  * clones the whole repo, so an in-repo sibling is present), and resolves
310
310
  * `file:`/`link:`/`portal:` against a path — allowed when that path stays
311
311
  * inside the cloned repo, rejected when it escapes the clone.
312
+ * - yarn classic (v1) has no `workspace:` protocol and no `portal:` — it links
313
+ * in-repo siblings by version range, not by a local specifier — so both are
314
+ * rejected with guidance; `file:`/`link:` are allowed when the path stays
315
+ * inside the clone, rejected when it escapes. (yarn berry is not yet
316
+ * supported.)
312
317
  *
313
318
  * This module performs that classification so unresolvable specifiers fail
314
319
  * fast with guidance rather than a cryptic install error.
@@ -405,6 +410,17 @@ function isInsideRepo(repoRoot, target) {
405
410
  */
406
411
  async function findUnresolvableDeps(deps, packageManager, kiciDir, repoRoot) {
407
412
  if (packageManager === PackageManager.Npm) return [...deps];
413
+ if (packageManager === PackageManager.Yarn) {
414
+ const unresolvable = [];
415
+ for (const dep of deps) {
416
+ if (dep.protocol === "workspace:" || dep.protocol === "portal:") {
417
+ unresolvable.push(dep);
418
+ continue;
419
+ }
420
+ if (!isInsideRepo(repoRoot, resolveLocalPath(kiciDir, dep))) unresolvable.push(dep);
421
+ }
422
+ return unresolvable;
423
+ }
408
424
  const hasWorkspaceFile = await fileExists(join(repoRoot, "pnpm-workspace.yaml"));
409
425
  const unresolvable = [];
410
426
  for (const dep of deps) {
@@ -420,6 +436,7 @@ async function findUnresolvableDeps(deps, packageManager, kiciDir, repoRoot) {
420
436
  function formatUnresolvableDepError(offenders, packageManager) {
421
437
  const list = offenders.map((o) => `${o.name}: ${o.spec}`).join(", ");
422
438
  if (packageManager === PackageManager.Npm) return `These .kici/ dependencies use local-protocol specifiers npm cannot resolve from a registry: ${list}. npm has no workspace protocol — pin a published version, publish the package to your registry, or use pnpm so an in-repo workspace sibling can be resolved.`;
439
+ if (packageManager === PackageManager.Yarn) return `These .kici/ dependencies use specifiers yarn classic cannot resolve: ${list}. yarn classic has no workspace: or portal: protocol — reference an in-repo sibling by a version range (yarn links matching workspace members), use pnpm, or keep file:/link: paths inside this repository. (yarn berry support is planned.)`;
423
440
  return `These .kici/ dependencies point outside the cloned repository, which the agent never has: ${list}. A workspace: dependency requires a pnpm-workspace.yaml at the repo root, and file:/link:/portal: paths must stay inside this repository.`;
424
441
  }
425
442
  /**
@@ -438,6 +455,101 @@ async function assertResolvableDeps(args) {
438
455
  throw new Error(formatUnresolvableDepError(offenders, args.packageManager));
439
456
  }
440
457
  //#endregion
458
+ //#region src/execution/workspace-siblings.ts
459
+ /**
460
+ * In-repo workspace-sibling discovery for the agent's dependency handling.
461
+ *
462
+ * A pnpm or yarn-classic workspace lays out a `.kici/` member's `workspace:`
463
+ * (pnpm) or version-range (yarn) siblings as symlinks pointing at package
464
+ * directories that live inside the clone but outside `.kici/` and outside the
465
+ * `node_modules` store. The dep-cache packer must travel those sibling dirs with
466
+ * the closure (their symlinks would dangle otherwise), and the yarn install path
467
+ * must build them (the install links a sibling but does not build it).
468
+ *
469
+ * `collectInRepoSiblings` walks a starting `node_modules` (and transitively each
470
+ * discovered sibling's `node_modules`), returning each in-repo sibling directory
471
+ * once, repo-root-relative, in breadth-first discovery order. The starting
472
+ * `node_modules` is a parameter so it serves pnpm + yarn-standalone (seeded at
473
+ * `.kici/node_modules`) and yarn-workspace-member (seeded at the hoisted root
474
+ * `node_modules`).
475
+ */
476
+ /**
477
+ * The directory yarn lays `.kici`'s dependencies into. A standalone `.kici`
478
+ * (own lockfile, no parent workspace) gets `.kici/node_modules`; a workspace
479
+ * member hoists everything to the repo-root `node_modules`, leaving no
480
+ * `.kici/node_modules`.
481
+ */
482
+ function resolveYarnNodeModulesRoot(repoRoot, kiciDir) {
483
+ const kiciNm = join(kiciDir, "node_modules");
484
+ return existsSync(kiciNm) ? kiciNm : join(repoRoot, "node_modules");
485
+ }
486
+ /**
487
+ * Walk `seedNodeModules` (and transitively each in-repo sibling's
488
+ * `node_modules`) collecting the repo-root-relative directories of workspace
489
+ * siblings — package dirs that live inside the clone but outside `.kici/` and
490
+ * outside the repo-root `node_modules/` store. Returns each dir once, in
491
+ * discovery (BFS) order.
492
+ */
493
+ async function collectInRepoSiblings(workDir, kiciDir, seedNodeModules = join(kiciDir, "node_modules")) {
494
+ const repoRoot = resolve(workDir);
495
+ const kiciResolved = resolve(kiciDir);
496
+ const rootNodeModules = resolve(join(workDir, "node_modules"));
497
+ const found = /* @__PURE__ */ new Set();
498
+ const visited = /* @__PURE__ */ new Set();
499
+ const queue = [seedNodeModules];
500
+ while (queue.length > 0) {
501
+ const nmDir = queue.shift();
502
+ const real = await realpath(nmDir).catch(() => null);
503
+ if (!real || visited.has(real)) continue;
504
+ visited.add(real);
505
+ for (const target of await resolveNodeModulesLinks(nmDir)) {
506
+ if (!isInside(repoRoot, target)) continue;
507
+ if (isInside(kiciResolved, target) || isInside(rootNodeModules, target)) continue;
508
+ const rel = relative(workDir, target);
509
+ if (!found.has(rel)) {
510
+ found.add(rel);
511
+ queue.push(join(target, "node_modules"));
512
+ }
513
+ }
514
+ }
515
+ return [...found];
516
+ }
517
+ /** Resolve every package symlink target under a `node_modules` dir (descending one level into `@scope` dirs). */
518
+ async function resolveNodeModulesLinks(nmDir) {
519
+ const targets = [];
520
+ for (const entry of await readdir(nmDir).catch(() => [])) {
521
+ if (entry.startsWith(".")) continue;
522
+ const entryPath = join(nmDir, entry);
523
+ if (entry.startsWith("@")) {
524
+ for (const scoped of await readdir(entryPath).catch(() => [])) {
525
+ const target = await resolveIfSymlink(join(entryPath, scoped));
526
+ if (target) targets.push(target);
527
+ }
528
+ continue;
529
+ }
530
+ const target = await resolveIfSymlink(entryPath);
531
+ if (target) targets.push(target);
532
+ }
533
+ return targets;
534
+ }
535
+ /** Return the real path of `p` if it is a symlink, else null. */
536
+ async function resolveIfSymlink(p) {
537
+ try {
538
+ if (!(await lstat(p)).isSymbolicLink()) return null;
539
+ return await realpath(p);
540
+ } catch {
541
+ return null;
542
+ }
543
+ }
544
+ /** Whether `target` is `root` itself or a path inside it. */
545
+ function isInside(root, target) {
546
+ const rel = relative(root, target);
547
+ return rel === "" || !rel.startsWith("..") && !rel.startsWith(`..${sep}`) && !isAbsoluteRel(rel);
548
+ }
549
+ function isAbsoluteRel(rel) {
550
+ return rel.length > 1 && rel[1] === ":";
551
+ }
552
+ //#endregion
441
553
  //#region src/execution/dep-installer.ts
442
554
  /**
443
555
  * Inline dependency installation for graceful degradation.
@@ -445,12 +557,14 @@ async function assertResolvableDeps(args) {
445
557
  * When the dep cache is unavailable or a download fails, the agent installs
446
558
  * `.kici/` dependencies directly with the repository's package manager.
447
559
  *
448
- * The package manager is detected from the cloned repo (npm / pnpm); the
560
+ * The package manager is detected from the cloned repo (npm / pnpm / yarn); the
449
561
  * presence of `.kici/package.json` signals that deps should be installed. npm
450
562
  * is the default and ships with every Node.js install; pnpm is used when the
451
563
  * repo is a pnpm workspace so a `.kici/` member can resolve in-repo
452
- * `workspace:` siblings. yarn is detected but not yet supported and is
453
- * rejected with an actionable error.
564
+ * `workspace:` siblings. yarn classic (v1) is supported for registry
565
+ * dependencies and version-range workspace siblings (which it links but does
566
+ * not build, so the agent builds the in-repo closure after install). yarn
567
+ * berry (v2+) is not yet supported.
454
568
  *
455
569
  * Security: the install runs with an isolated per-invocation cache/store
456
570
  * directory to prevent cache poisoning across build jobs — a malicious
@@ -499,7 +613,6 @@ async function installDeps(kiciDir, opts = {}) {
499
613
  dir: kiciDir
500
614
  });
501
615
  process.stderr.write(`[dep-installer:trace] starting install: pm=${packageManager}, cwd=${kiciDir}\n`);
502
- if (packageManager === PackageManager.Yarn) throw new Error("This repository uses yarn, which the KiCI agent does not yet support for .kici/ dependency installation. Use npm or pnpm for the .kici/ project, or open a feature request for yarn support.");
503
616
  await assertResolvableDeps({
504
617
  kiciDir,
505
618
  repoRoot,
@@ -519,6 +632,11 @@ async function installDeps(kiciDir, opts = {}) {
519
632
  hasPrivateRegistry,
520
633
  registryConfig
521
634
  });
635
+ else if (packageManager === PackageManager.Yarn) await runYarnInstall({
636
+ kiciDir,
637
+ hasPrivateRegistry,
638
+ registryConfig
639
+ });
522
640
  else await runNpmInstall({
523
641
  kiciDir,
524
642
  hasPrivateRegistry,
@@ -533,6 +651,7 @@ async function installDeps(kiciDir, opts = {}) {
533
651
  await registryConfig.cleanup();
534
652
  }
535
653
  if (packageManager === PackageManager.Pnpm && await kiciHasLocalProtocolDeps(kiciDir)) await buildWorkspaceClosure(repoRoot);
654
+ if (packageManager === PackageManager.Yarn) await buildYarnWorkspaceClosure(repoRoot, kiciDir);
536
655
  const durationMs = Date.now() - startTime;
537
656
  process.stderr.write(`[dep-installer:trace] install complete: ${durationMs}ms\n`);
538
657
  logger$2.info("Deps installed inline", {
@@ -620,6 +739,101 @@ async function runPnpmInstall(args) {
620
739
  }).catch(() => {});
621
740
  }
622
741
  }
742
+ /** Pure: argv for `yarn install` with an isolated cache folder. */
743
+ function buildYarnInstallArgs(cacheDir, hasPrivateRegistry) {
744
+ const a = [
745
+ "install",
746
+ "--cache-folder",
747
+ cacheDir,
748
+ "--non-interactive",
749
+ "--no-progress"
750
+ ];
751
+ if (hasPrivateRegistry) a.push("--ignore-scripts");
752
+ return a;
753
+ }
754
+ /**
755
+ * Run `yarn install` from `.kici/` with an isolated cache folder. yarn classic
756
+ * reads the synthesized `.kici/.npmrc` (registry + `${VAR}` token expansion) for
757
+ * private-registry auth. A workspace member hoists deps to the repo-root
758
+ * node_modules; a standalone `.kici` gets `.kici/node_modules`. Not
759
+ * `--frozen-lockfile` (resolved URLs in the lockfile may point at a different
760
+ * registry than the synthesized `.npmrc`, e.g. localhost tunnel vs direct IP).
761
+ */
762
+ async function runYarnInstall(args) {
763
+ await assertYarnAvailable();
764
+ const { nodeDir } = resolveNpm();
765
+ const cacheDir = await mkdtemp(join(tmpdir(), "kici-yarn-cache-"));
766
+ const env = envWithNodeOnPath(args.registryConfig.extraEnv, nodeDir);
767
+ const argv = buildYarnInstallArgs(cacheDir, args.hasPrivateRegistry);
768
+ try {
769
+ process.stderr.write(`[dep-installer:trace] running: yarn ${argv.join(" ")}\n`);
770
+ await execFileAsync("yarn", argv, {
771
+ cwd: args.kiciDir,
772
+ env,
773
+ timeout: INSTALL_TIMEOUT_MS,
774
+ maxBuffer: INSTALL_MAX_BUFFER
775
+ });
776
+ } finally {
777
+ await rm(cacheDir, {
778
+ recursive: true,
779
+ force: true
780
+ }).catch(() => {});
781
+ }
782
+ }
783
+ /** Throw an actionable error when the repo needs yarn but it is not installed. */
784
+ async function assertYarnAvailable() {
785
+ try {
786
+ await execFileAsync("yarn", ["--version"], {
787
+ timeout: 3e4,
788
+ cwd: tmpdir()
789
+ });
790
+ } catch (e) {
791
+ throw new Error(`This repository uses yarn, but yarn is not available on this agent. Install yarn (e.g. \`corepack enable\`) or run on a container/Firecracker agent that bundles it. (${toErrorMessage(e)})`);
792
+ }
793
+ }
794
+ /**
795
+ * Build the in-repo workspace siblings `.kici` depends on (yarn links them on
796
+ * install but does not build them). Walks siblings from the resolved
797
+ * node_modules root and runs each sibling's `build` script in leaf-first
798
+ * (reverse-discovery) order with a clean env (no synthesized registry tokens).
799
+ * Deep cross-sibling build chains may build out of strict topological order —
800
+ * real `.kici` closures are shallow.
801
+ */
802
+ async function buildYarnWorkspaceClosure(repoRoot, kiciDir) {
803
+ const siblings = await collectInRepoSiblings(repoRoot, kiciDir, resolveYarnNodeModulesRoot(repoRoot, kiciDir));
804
+ if (siblings.length === 0) return;
805
+ const { nodeDir } = resolveNpm();
806
+ const env = envWithNodeOnPath({}, nodeDir);
807
+ for (const rel of [...siblings].reverse()) {
808
+ const sibDir = join(repoRoot, rel);
809
+ if (!await siblingHasBuildScript(sibDir)) continue;
810
+ process.stderr.write(`[dep-installer:trace] building yarn sibling: yarn --cwd ${sibDir} run build\n`);
811
+ try {
812
+ await execFileAsync("yarn", [
813
+ "--cwd",
814
+ sibDir,
815
+ "run",
816
+ "build"
817
+ ], {
818
+ cwd: repoRoot,
819
+ env,
820
+ timeout: INSTALL_TIMEOUT_MS,
821
+ maxBuffer: INSTALL_MAX_BUFFER
822
+ });
823
+ } catch (e) {
824
+ logSubprocessStreams(e, []);
825
+ throw new Error(`Failed to build .kici yarn workspace sibling ${rel}: ${describeExecError(e)}`);
826
+ }
827
+ }
828
+ }
829
+ /** Whether a sibling package.json declares a `build` script. */
830
+ async function siblingHasBuildScript(sibDir) {
831
+ try {
832
+ return typeof JSON.parse(await readFile(join(sibDir, "package.json"), "utf-8")).scripts?.build === "string";
833
+ } catch {
834
+ return false;
835
+ }
836
+ }
623
837
  /**
624
838
  * Build the in-repo dependency closure of the `.kici/` package so a
625
839
  * `workspace:` sibling's build output exists before the workflow that imports
@@ -663,7 +877,10 @@ function describeExecError(e) {
663
877
  /** Throw an actionable error when the repo needs pnpm but it is not installed. */
664
878
  async function assertPnpmAvailable() {
665
879
  try {
666
- await execFileAsync("pnpm", ["--version"], { timeout: 3e4 });
880
+ await execFileAsync("pnpm", ["--version"], {
881
+ timeout: 3e4,
882
+ cwd: tmpdir()
883
+ });
667
884
  } catch (e) {
668
885
  throw new Error(`This repository is a pnpm workspace, but pnpm is not available on this agent. Install pnpm (e.g. \`corepack enable\`) or run on a container/ Firecracker agent that bundles it. (${toErrorMessage(e)})`);
669
886
  }
@@ -0,0 +1,30 @@
1
+ import { type KiciBundle } from '@kici-dev/engine/provenance/bundle';
2
+ import type { OidcTokenResult } from '@kici-dev/engine/protocol/messages/oidc-token-relay';
3
+ import { type ProvenanceSubject } from './statement-builder.js';
4
+ export interface AttestDeps {
5
+ /** P1.4 relay: returns a KiCI ID token bound to the current job. */
6
+ getIdToken: (opts: {
7
+ audience: string;
8
+ }) => Promise<OidcTokenResult>;
9
+ /** Upload the serialized bundle; returns the storage key it was written to. */
10
+ persist: (bundle: KiciBundle, subjectDigest: string) => Promise<string>;
11
+ builderVersions: {
12
+ 'kici-agent': string;
13
+ 'kici-orchestrator': string;
14
+ };
15
+ /** Injectable clock for deterministic tests; defaults to wall time. */
16
+ now?: () => string;
17
+ }
18
+ export interface AttestInput {
19
+ subject: ProvenanceSubject;
20
+ audience?: string;
21
+ }
22
+ export interface AttestResult {
23
+ storageKey: string;
24
+ bundle: KiciBundle;
25
+ subjectDigest: string;
26
+ }
27
+ export declare function attestProvenance(deps: AttestDeps, input: AttestInput): Promise<AttestResult>;
28
+ /** Pick the primary digest (`sha256` preferred) as the storage-key discriminator. */
29
+ export declare function subjectDigestString(subject: ProvenanceSubject): string;
30
+ //# sourceMappingURL=attest.d.ts.map
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Ephemeral-key DSSE signer for KiCI provenance (Mode A).
3
+ *
4
+ * Generates a fresh in-process ES256 keypair (never persisted), DSSE-signs the
5
+ * PAE of the statement bytes with the private half, and returns the envelope
6
+ * plus the public JWK. The public key travels in the bundle so the verifier can
7
+ * check the signature; the key needs no separate trust root because the bundle's
8
+ * identity JWT (verified against the Platform JWKS) anchors the whole package.
9
+ */
10
+ import { type JWK } from 'jose';
11
+ import { type DsseEnvelope } from '@kici-dev/engine/provenance/dsse';
12
+ export interface SignedStatement {
13
+ envelope: DsseEnvelope;
14
+ /** Ephemeral public key as a JWK, with `kid` = its RFC 7638 thumbprint. */
15
+ publicJwk: JWK & {
16
+ kid: string;
17
+ };
18
+ }
19
+ /** DSSE-sign `statementBytes` with a fresh in-process ephemeral ES256 key. */
20
+ export declare function signStatementDsse(payloadType: string, statementBytes: Uint8Array): Promise<SignedStatement>;
21
+ //# sourceMappingURL=sign.d.ts.map
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Build a SLSA v1.0 in-toto provenance statement from the server-truth identity
3
+ * token claims plus the caller-supplied subject. The build context comes
4
+ * entirely from the JWT claims (Platform-minted, unforgeable), so the
5
+ * statement's identity equals the token's identity by construction.
6
+ */
7
+ import { type KiciProvenanceStatement } from '@kici-dev/engine/provenance/schema';
8
+ /** The KiCI identity-token claims the builder reads (Platform server-truth). */
9
+ export interface ProvenanceTokenClaims {
10
+ iss: string;
11
+ repository?: string | null;
12
+ ref?: string | null;
13
+ sha?: string | null;
14
+ workflow_ref?: string | null;
15
+ kici_run_id: string;
16
+ kici_job_id: string;
17
+ orchestrator_id?: string | null;
18
+ }
19
+ /** Caller-supplied artifact subject: a name plus a lowercase-hex digest map. */
20
+ export interface ProvenanceSubject {
21
+ name: string;
22
+ digest: Record<string, string>;
23
+ }
24
+ export interface BuildStatementInput {
25
+ tokenClaims: ProvenanceTokenClaims;
26
+ subject: ProvenanceSubject;
27
+ builderVersions: {
28
+ 'kici-agent': string;
29
+ 'kici-orchestrator': string;
30
+ };
31
+ /** ISO-8601 timestamp with offset. */
32
+ startedOn: string;
33
+ /** ISO-8601 timestamp with offset. */
34
+ finishedOn: string;
35
+ }
36
+ /** Build a KiCI SLSA v1.0 provenance statement (validates against the P1.1 schema). */
37
+ export declare function buildProvenanceStatement(input: BuildStatementInput): KiciProvenanceStatement;
38
+ //# sourceMappingURL=statement-builder.d.ts.map