File size: 3,172 Bytes
d197cf3
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
import { createHash } from "node:crypto";
import { realpath } from "node:fs/promises";
import { resolve } from "node:path";
import { MAX_WORKSPACE_BOOTSTRAP_FILE_BYTES } from "../agents/workspace-bootstrap-read.js";
import { DEFAULT_BOOTSTRAP_FILENAME, seedWorkspaceBootstrap } from "../agents/workspace.js";
import { root as fsSafeRoot } from "../infra/fs-safe.js";
import type { OpenClawStateDatabaseOptions } from "../state/openclaw-state-db.js";
import { clawContainedRelativePath } from "./path-containment.js";
import type { ClawAddPlan } from "./types.js";

export class ClawBootstrapWriteError extends Error {
  constructor(
    readonly code: string,
    message: string,
  ) {
    super(message);
    this.name = "ClawBootstrapWriteError";
  }
}

function contentDigest(content: Uint8Array): string {
  return `sha256:${createHash("sha256").update(content).digest("hex")}`;
}

export async function seedClawPackageBootstrap(
  plan: ClawAddPlan,
  options: {
    nowMs?: number;
    seedBootstrap?: typeof seedWorkspaceBootstrap;
  } & OpenClawStateDatabaseOptions = {},
): Promise<"seeded" | "already-seeded" | "consumed" | undefined> {
  const actions = plan.actions.filter((action) => action.kind === "bootstrap");
  if (actions.length === 0) {
    return undefined;
  }
  if (actions.length !== 1) {
    throw new ClawBootstrapWriteError(
      "bootstrap_plan_invalid",
      "A Claw add plan may contain only one package bootstrap action.",
    );
  }
  const action = actions[0];
  if (!action) {
    throw new ClawBootstrapWriteError(
      "bootstrap_plan_invalid",
      "The package bootstrap action is missing.",
    );
  }
  if (!action.source || !action.digest) {
    throw new ClawBootstrapWriteError(
      "bootstrap_plan_invalid",
      "The package bootstrap action lacks source integrity.",
    );
  }

  const packageRoot = await realpath(resolve(plan.claw.packageRoot));
  const sourcePath = resolve(action.source);
  const sourceRelative = clawContainedRelativePath(packageRoot, sourcePath);
  if (!sourceRelative) {
    throw new ClawBootstrapWriteError(
      "bootstrap_source_escape",
      "BOOTSTRAP.md must remain inside the Claw package.",
    );
  }
  const sourceRoot = await fsSafeRoot(packageRoot);
  const read = await sourceRoot.read(sourceRelative, {
    hardlinks: "reject",
    maxBytes: MAX_WORKSPACE_BOOTSTRAP_FILE_BYTES,
    symlinks: "reject",
  });
  if (resolve(read.realPath) !== sourcePath || contentDigest(read.buffer) !== action.digest) {
    throw new ClawBootstrapWriteError(
      "bootstrap_source_changed",
      "BOOTSTRAP.md changed after consent; run add --dry-run again.",
    );
  }

  const expectedTarget = resolve(plan.agent.workspace, DEFAULT_BOOTSTRAP_FILENAME);
  if (resolve(action.target) !== expectedTarget) {
    throw new ClawBootstrapWriteError(
      "bootstrap_target_changed",
      "The package bootstrap target is not the new agent workspace root.",
    );
  }

  return (options.seedBootstrap ?? seedWorkspaceBootstrap)({
    dir: plan.agent.workspace,
    content: read.buffer,
    ...(options.nowMs !== undefined ? { nowMs: options.nowMs } : {}),
    stateOptions: options,
  });
}