Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/reference/troubleshooting.md
Original file line number Diff line number Diff line change
Expand Up @@ -390,7 +390,7 @@ Instead:

1. Upgrade to a NemoClaw release that includes the newer `openclaw` version.
2. If you build NemoClaw from source, bump the pinned `openclaw` version in `Dockerfile.base` and rebuild the sandbox base image.
3. Back up any workspace files you need, then recreate the sandbox so it uses the rebuilt image.
3. Run `nemoclaw <name> rebuild` to recreate the sandbox with the updated image. The rebuild command automatically backs up workspace state before destroying the old sandbox and restores it afterward.

### Inference requests time out

Expand Down
15 changes: 15 additions & 0 deletions nemoclaw/src/commands/migration-state.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -306,6 +306,21 @@ describe("commands/migration-state", () => {
expect(result.externalRoots.some((r) => r.kind === "skillsExtraDir")).toBe(true);
});

it("resolves external roots against the provided env", () => {
const env = { HOME: "/home/user", OPENCLAW_HOME: "/custom/home" };
addDir("/custom/home/.openclaw");
addFile(
"/custom/home/.openclaw/openclaw.json",
JSON.stringify({
skills: { load: { extraDirs: ["~/skills-extra"] } },
}),
);
addDir("/custom/home/skills-extra");

const result = detectHostOpenClaw(env);
expect(result.externalRoots[0]?.sourcePath).toBe("/custom/home/skills-extra");
});

it("warns about symlinks in workspace", () => {
const env = { HOME: "/home/user" };
addDir("/home/user/.openclaw");
Expand Down
232 changes: 125 additions & 107 deletions nemoclaw/src/commands/migration-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,44 @@ type CandidateRoot = {
required: boolean;
};

type OpenClawConfigDocument = Record<string, unknown>;
type UnknownRecord = { [key: string]: unknown };
type OpenClawConfigDocument = UnknownRecord;

function isRecord(value: unknown): value is UnknownRecord {
return typeof value === "object" && value !== null && !Array.isArray(value);
}

function readString(value: unknown): string | null {
return typeof value === "string" ? value : null;
}

function readTrimmedString(value: unknown): string | null {
const trimmed = readString(value)?.trim();
return trimmed ? trimmed : null;
}

function readRecord(value: unknown): UnknownRecord | null {
return isRecord(value) ? value : null;
}

function readRecordKey(
record: UnknownRecord | null | undefined,
key: string,
): UnknownRecord | null {
return readRecord(record?.[key]);
}

function readArrayKey(record: UnknownRecord | null | undefined, key: string): unknown[] | null {
const value = record?.[key];
return Array.isArray(value) ? value : null;
}

function parseConfigDocument(value: unknown, context: string): OpenClawConfigDocument {
if (!isRecord(value)) {
throw new Error(`${context} is not a JSON object.`);
}
return value;
}

function resolveHostHome(env: NodeJS.ProcessEnv = process.env): string {
const fallbackHome = env.HOME?.trim() || env.USERPROFILE?.trim() || os.homedir();
Expand Down Expand Up @@ -148,11 +185,7 @@ function loadConfigDocument(configPath: string): OpenClawConfigDocument | null {
return null;
}
const raw = readFileSync(configPath, "utf-8");
const parsed: unknown = JSON5.parse(raw);
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
throw new Error(`Config at ${configPath} is not a JSON object.`);
}
return parsed as OpenClawConfigDocument;
return parseConfigDocument(JSON5.parse(raw), `Config at ${configPath}`);
}

function collectSymlinkPaths(rootPath: string): string[] {
Expand Down Expand Up @@ -196,8 +229,9 @@ function registerRoot(
sandboxGroup: string;
required: boolean;
},
env: NodeJS.ProcessEnv = process.env,
): void {
const resolvedPath = resolveUserPath(params.pathValue);
const resolvedPath = resolveUserPath(params.pathValue, env);
const normalized = normalizeHostPath(resolvedPath);
const existing = rootMap.get(normalized);
if (existing) {
Expand Down Expand Up @@ -229,95 +263,93 @@ function defaultWorkspacePath(env: NodeJS.ProcessEnv = process.env): string {
function collectExternalRoots(
config: OpenClawConfigDocument | null,
stateDir: string,
env: NodeJS.ProcessEnv = process.env,
): { roots: MigrationExternalRoot[]; warnings: string[]; errors: string[] } {
const warnings: string[] = [];
const errors: string[] = [];
const rootMap = new Map<string, CandidateRoot>();

const agents = config?.["agents"];
const agentDefaults =
agents && typeof agents === "object" && !Array.isArray(agents)
? (agents as Record<string, unknown>)["defaults"]
: undefined;
const agentList =
agents && typeof agents === "object" && !Array.isArray(agents)
? (agents as Record<string, unknown>)["list"]
: undefined;
const skills = config?.["skills"];
const skillLoad =
skills && typeof skills === "object" && !Array.isArray(skills)
? (skills as Record<string, unknown>)["load"]
: undefined;

const defaultsWorkspace =
agentDefaults && typeof agentDefaults === "object" && !Array.isArray(agentDefaults)
? (agentDefaults as Record<string, unknown>)["workspace"]
: undefined;
const defaultWorkspace =
typeof defaultsWorkspace === "string" && defaultsWorkspace.trim()
? defaultsWorkspace.trim()
: defaultWorkspacePath();
registerRoot(rootMap, {
pathValue: defaultWorkspace,
kind: "workspace",
label: "default-workspace",
bindingPath: "agents.defaults.workspace",
sandboxGroup: "workspaces",
required: typeof defaultsWorkspace === "string" && defaultsWorkspace.trim().length > 0,
});
const agents = readRecordKey(config, "agents");
const agentDefaults = readRecordKey(agents, "defaults");
const agentList = readArrayKey(agents, "list");
const skillLoad = readRecordKey(readRecordKey(config, "skills"), "load");

if (Array.isArray(agentList)) {
const defaultsWorkspace = readTrimmedString(agentDefaults?.workspace);
const defaultWorkspace = defaultsWorkspace ?? defaultWorkspacePath(env);
registerRoot(
rootMap,
{
pathValue: defaultWorkspace,
kind: "workspace",
label: "default-workspace",
bindingPath: "agents.defaults.workspace",
sandboxGroup: "workspaces",
required: typeof defaultsWorkspace === "string" && defaultsWorkspace.trim().length > 0,
},
env,
);

if (agentList) {
agentList.forEach((entry, index) => {
if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
const agent = readRecord(entry);
if (!agent) {
return;
}
const agent = entry as Record<string, unknown>;
const agentId =
typeof agent["id"] === "string" && agent["id"].trim()
? agent["id"].trim()
: `agent-${String(index)}`;

if (typeof agent["workspace"] === "string" && agent["workspace"].trim()) {
registerRoot(rootMap, {
pathValue: agent["workspace"].trim(),
kind: "workspace",
label: `${agentId}-workspace`,
bindingPath: `agents.list[${String(index)}].workspace`,
sandboxGroup: "workspaces",
required: true,
});
const agentId = readTrimmedString(agent.id) ?? `agent-${String(index)}`;
const workspace = readTrimmedString(agent.workspace);
const agentDir = readTrimmedString(agent.agentDir);

if (workspace) {
registerRoot(
rootMap,
{
pathValue: workspace,
kind: "workspace",
label: `${agentId}-workspace`,
bindingPath: `agents.list[${String(index)}].workspace`,
sandboxGroup: "workspaces",
required: true,
},
env,
);
}

if (typeof agent["agentDir"] === "string" && agent["agentDir"].trim()) {
registerRoot(rootMap, {
pathValue: agent["agentDir"].trim(),
kind: "agentDir",
label: `${agentId}-agent-dir`,
bindingPath: `agents.list[${String(index)}].agentDir`,
sandboxGroup: "agent-dirs",
required: true,
});
if (agentDir) {
registerRoot(
rootMap,
{
pathValue: agentDir,
kind: "agentDir",
label: `${agentId}-agent-dir`,
bindingPath: `agents.list[${String(index)}].agentDir`,
sandboxGroup: "agent-dirs",
required: true,
},
env,
);
}
});
}

const extraDirs =
skillLoad && typeof skillLoad === "object" && !Array.isArray(skillLoad)
? (skillLoad as Record<string, unknown>)["extraDirs"]
: undefined;
if (Array.isArray(extraDirs)) {
const extraDirs = readArrayKey(skillLoad, "extraDirs");
if (extraDirs) {
extraDirs.forEach((entry, index) => {
if (typeof entry !== "string" || !entry.trim()) {
const extraDir = readTrimmedString(entry);
if (!extraDir) {
return;
}
registerRoot(rootMap, {
pathValue: entry.trim(),
kind: "skillsExtraDir",
label: `skills-extra-${String(index + 1)}`,
bindingPath: `skills.load.extraDirs[${String(index)}]`,
sandboxGroup: "skills",
required: true,
});
registerRoot(
rootMap,
{
pathValue: extraDir,
kind: "skillsExtraDir",
label: `skills-extra-${String(index + 1)}`,
bindingPath: `skills.load.extraDirs[${String(index)}]`,
sandboxGroup: "skills",
required: true,
},
env,
);
});
}

Expand Down Expand Up @@ -413,29 +445,16 @@ export function detectHostOpenClaw(env: NodeJS.ProcessEnv = process.env): HostOp
errors.push(`Failed to parse OpenClaw config at ${configPath}: ${msg}`);
}

const rootInfo = collectExternalRoots(config, stateDir);
const rootInfo = collectExternalRoots(config, stateDir, env);
warnings.push(...rootInfo.warnings);
errors.push(...rootInfo.errors);

const workspaceDir =
config &&
typeof config["agents"] === "object" &&
config["agents"] &&
!Array.isArray(config["agents"]) &&
typeof (
(config["agents"] as Record<string, unknown>)["defaults"] as
| Record<string, unknown>
| undefined
)?.["workspace"] === "string"
? resolveUserPath(
(
((config["agents"] as Record<string, unknown>)["defaults"] as Record<string, unknown>)[
"workspace"
] as string
).trim(),
env,
)
: defaultWorkspacePath(env);
const defaultsWorkspace = readTrimmedString(
readRecordKey(readRecordKey(config, "agents"), "defaults")?.workspace,
);
const workspaceDir = defaultsWorkspace
? resolveUserPath(defaultsWorkspace, env)
: defaultWorkspacePath(env);

const extensionsDir = existsSync(path.join(stateDir, "extensions"))
? path.join(stateDir, "extensions")
Expand Down Expand Up @@ -521,9 +540,10 @@ function stripCredentials(obj: unknown): unknown {
if (obj === null || obj === undefined) return obj;
if (typeof obj !== "object") return obj;
if (Array.isArray(obj)) return obj.map(stripCredentials);
if (!isRecord(obj)) return obj;

const result: Record<string, unknown> = {};
for (const [key, value] of Object.entries(obj as Record<string, unknown>)) {
const result: UnknownRecord = {};
for (const [key, value] of Object.entries(obj)) {
if (isCredentialField(key)) {
result[key] = "[STRIPPED_BY_MIGRATION]";
} else {
Expand All @@ -538,13 +558,11 @@ function stripCredentials(obj: unknown): unknown {
* config section (contains auth tokens — regenerated by sandbox entrypoint).
*/
function sanitizeConfigFile(configPath: string): void {
if (!existsSync(configPath)) return;
const raw = readFileSync(configPath, "utf-8");
const parsed: unknown = JSON5.parse(raw);
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return;
const config = parsed as Record<string, unknown>;
delete config["gateway"];
const sanitized = stripCredentials(config) as Record<string, unknown>;
const config = loadConfigDocument(configPath);
if (!config) return;
delete config.gateway;
const sanitized = stripCredentials(config);
if (!isRecord(sanitized)) return;
writeFileSync(configPath, JSON.stringify(sanitized, null, 2));
chmodSync(configPath, 0o600);
}
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
"ts-migration:assist": "tsx scripts/ts-migration-assist.ts",
"ts-migration:bulk-fix-prs": "tsx scripts/ts-migration-bulk-fix-prs.ts",
"ts-migration:guard": "tsx scripts/check-legacy-migrated-paths.ts",
"type-safety:hotspots": "tsx scripts/type-safety-hotspots.ts",
"bump:version": "tsx scripts/bump-version.ts",
"prepare": "if command -v tsc >/dev/null 2>&1 || [ -x node_modules/.bin/tsc ]; then npm run build:cli; fi && npm install --omit=dev --ignore-scripts 2>/dev/null || true && if [ -d .git ]; then if command -v prek >/dev/null 2>&1; then prek install; else echo \"Skipping git hook setup (prek not installed)\"; fi; fi",
"prepublishOnly": "git describe --tags --match 'v*' | sed 's/^v//' > .version && test -s .version && cd nemoclaw && env -u npm_config_global -u npm_config_prefix -u npm_config_omit npm install --ignore-scripts && ./node_modules/.bin/tsc"
Expand Down
Loading
Loading