Skip to content

Commit 2ff6c48

Browse files
committed
sync(bfmono): feat(gambit-bot): add file actions and scenario deck structure (+19 more) (bfmono@dbe7c54ca)
This PR is an automated gambitmono sync of bfmono Gambit packages. - Source: `packages/gambit/` - Core: `packages/gambit-core/` - bfmono rev: dbe7c54ca Changes: - dbe7c54ca feat(gambit-bot): add file actions and scenario deck structure - 855784d6b docs(gambit): add public permissions guide and API jsdoc - 8f0ca0a85 feat(gambit): trace effective permission layers at runtime - 90b4b5071 feat(gambit-core): add phase-1 permission contract primitives - df9280f6a fix(gambit): restore build-bot deck path compatibility - daca46555 feat(simulator-ui): wire build, test, and grade to workspace sessions - e404a17d7 feat(gambit): add workspace-backed serve and bot sandbox flow - 5f4fa86b9 feat(gambit): scaffold workspace defaults in init - cf9b23778 feat(gambit-core): add schema guards and model param passthrough - d0e5a9617 [gambit] move chat message into transcript so it scrolls - 5c6125d99 feat(simulator-ui): open workbench drawer by default - 7c9cd05f8 feat(simulator): gate chat accordion by env flag - a2599068e feat(simulator-ui): add build chat history loading - 9911dae22 feat(simulator-ui): add workbench chat drawer accordion - 8cab8ec1f feat(simulator-ui): dock calibrate drawer and sync updates - d41ba101d Add AAR for phase 3.1.5 deck format build tab - b1b5e2a7e Prefer PROMPT.md after build scaffolds - 50fac8f7b Update Build tab for deck format 1.0 - c5d99df6a feat(gambit-core): add deck format 1.0 stdlib assets - e76d1c56f feat(gambit): avoid invalid test deck fetch on stale selection Do not edit this repo directly; make changes in bfmono and re-run the sync.
1 parent 1ee565d commit 2ff6c48

20 files changed

Lines changed: 1198 additions & 65 deletions

File tree

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
import { defineDeck } from "jsr:@bolt-foundry/gambit";
2+
import { z } from "npm:zod";
3+
import { resolveBotPath } from "./bot_fs.ts";
4+
5+
export default defineDeck({
6+
label: "bot_delete",
7+
contextSchema: z.object({
8+
path: z.string().describe("Relative path under the bot root."),
9+
recursive: z.boolean().default(false).describe(
10+
"When true, allows deleting non-empty directories.",
11+
),
12+
}),
13+
responseSchema: z.object({
14+
status: z.number().optional(),
15+
message: z.string().optional(),
16+
payload: z.object({
17+
path: z.string(),
18+
deleted: z.boolean(),
19+
type: z.enum(["file", "directory"]),
20+
recursive: z.boolean(),
21+
}).optional(),
22+
}),
23+
async run(ctx) {
24+
let resolved;
25+
try {
26+
resolved = await resolveBotPath(ctx.input.path);
27+
} catch (err) {
28+
return {
29+
status: 400,
30+
message: err instanceof Error ? err.message : String(err),
31+
};
32+
}
33+
34+
let stat: Deno.FileInfo;
35+
try {
36+
stat = await Deno.stat(resolved.fullPath);
37+
} catch (err) {
38+
if (err instanceof Deno.errors.NotFound) {
39+
return { status: 404, message: "path not found" };
40+
}
41+
return {
42+
status: 500,
43+
message: err instanceof Error ? err.message : String(err),
44+
};
45+
}
46+
47+
const recursive = Boolean(ctx.input.recursive);
48+
if (stat.isDirectory && !recursive) {
49+
try {
50+
for await (const _ of Deno.readDir(resolved.fullPath)) {
51+
return {
52+
status: 409,
53+
message:
54+
"directory is not empty; set recursive=true to delete recursively",
55+
};
56+
}
57+
} catch (err) {
58+
return {
59+
status: 500,
60+
message: err instanceof Error ? err.message : String(err),
61+
};
62+
}
63+
}
64+
65+
try {
66+
await Deno.remove(resolved.fullPath, { recursive });
67+
return {
68+
status: 200,
69+
payload: {
70+
path: resolved.relativePath,
71+
deleted: true,
72+
type: stat.isDirectory ? "directory" : "file",
73+
recursive,
74+
},
75+
};
76+
} catch (err) {
77+
if (err instanceof Deno.errors.NotFound) {
78+
return { status: 404, message: "path not found" };
79+
}
80+
return {
81+
status: 500,
82+
message: err instanceof Error ? err.message : String(err),
83+
};
84+
}
85+
},
86+
});
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
+++
2+
label = "bot_delete"
3+
execute = "../bot_delete.deck.ts"
4+
+++
5+
6+
Compute-only deck for deleting files or directories under the bot root.

src/decks/actions/bot_fs.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,8 +35,10 @@ export async function resolveBotPath(
3535
throw new Error("path escapes bot root");
3636
}
3737

38+
let candidateStat: Deno.FileInfo | null = null;
3839
try {
3940
const stat = await Deno.lstat(candidate);
41+
candidateStat = stat;
4042
if (stat.isSymlink) {
4143
throw new Error("symlinks are not allowed");
4244
}
@@ -51,7 +53,10 @@ export async function resolveBotPath(
5153
}
5254
}
5355

54-
const parent = await resolveExistingParent(path.dirname(candidate));
56+
const parentAnchor = candidateStat?.isDirectory
57+
? candidate
58+
: path.dirname(candidate);
59+
const parent = await resolveExistingParent(parentAnchor);
5560
const parentReal = await Deno.realPath(parent);
5661
const parentRelative = path.relative(root, parentReal);
5762
if (parentRelative.startsWith("..") || path.isAbsolute(parentRelative)) {

src/decks/actions/bot_list.deck.ts

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
import * as path from "@std/path";
2+
import { defineDeck } from "jsr:@bolt-foundry/gambit";
3+
import { z } from "npm:zod";
4+
import { resolveBotPath } from "./bot_fs.ts";
5+
6+
const MAX_RECURSION_DEPTH = 6;
7+
8+
type BotListEntry = {
9+
path: string;
10+
type: "file" | "dir";
11+
size?: number;
12+
modifiedAt?: string;
13+
};
14+
15+
export default defineDeck({
16+
label: "bot_list",
17+
contextSchema: z.object({
18+
path: z.string().default(".").describe(
19+
"Relative directory path under the bot root. Use '.' for root.",
20+
),
21+
recursive: z.boolean().default(false).describe(
22+
"When true, include nested entries.",
23+
),
24+
maxDepth: z.number().int().min(1).max(MAX_RECURSION_DEPTH).default(2)
25+
.describe(
26+
"Max nested directory depth when recursive is true.",
27+
),
28+
}),
29+
responseSchema: z.object({
30+
status: z.number().optional(),
31+
message: z.string().optional(),
32+
payload: z.object({
33+
path: z.string(),
34+
recursive: z.boolean(),
35+
maxDepth: z.number().int(),
36+
entries: z.array(z.object({
37+
path: z.string(),
38+
type: z.enum(["file", "dir"]),
39+
size: z.number().optional(),
40+
modifiedAt: z.string().optional(),
41+
})),
42+
}).optional(),
43+
}),
44+
async run(ctx) {
45+
let resolved;
46+
try {
47+
resolved = await resolveBotPath(ctx.input.path || ".");
48+
} catch (err) {
49+
return {
50+
status: 400,
51+
message: err instanceof Error ? err.message : String(err),
52+
};
53+
}
54+
55+
let rootStat: Deno.FileInfo;
56+
try {
57+
rootStat = await Deno.stat(resolved.fullPath);
58+
} catch (err) {
59+
if (err instanceof Deno.errors.NotFound) {
60+
return { status: 404, message: "path not found" };
61+
}
62+
return {
63+
status: 500,
64+
message: err instanceof Error ? err.message : String(err),
65+
};
66+
}
67+
if (!rootStat.isDirectory) {
68+
return { status: 409, message: "path is not a directory" };
69+
}
70+
71+
const recursive = Boolean(ctx.input.recursive);
72+
const maxDepth = recursive
73+
? Math.min(
74+
Math.max(1, Math.trunc(ctx.input.maxDepth ?? 2)),
75+
MAX_RECURSION_DEPTH,
76+
)
77+
: 1;
78+
const entries: Array<BotListEntry> = [];
79+
const baseRelativePath = normalizeRelativePath(resolved.relativePath);
80+
const walk = async (dir: string, prefix: string, depth: number) => {
81+
const dirEntries = [];
82+
for await (const entry of Deno.readDir(dir)) {
83+
if (entry.isSymlink) continue;
84+
dirEntries.push(entry);
85+
}
86+
dirEntries.sort((a, b) => a.name.localeCompare(b.name));
87+
for (const entry of dirEntries) {
88+
const fullPath = path.join(dir, entry.name);
89+
const relativePath = prefix === "."
90+
? entry.name
91+
: path.join(prefix, entry.name);
92+
if (entry.isDirectory) {
93+
entries.push({ path: relativePath, type: "dir" });
94+
if (recursive && depth < maxDepth) {
95+
await walk(fullPath, relativePath, depth + 1);
96+
}
97+
continue;
98+
}
99+
if (entry.isFile) {
100+
const info = await Deno.stat(fullPath);
101+
entries.push({
102+
path: relativePath,
103+
type: "file",
104+
size: info.size,
105+
modifiedAt: info.mtime ? info.mtime.toISOString() : undefined,
106+
});
107+
}
108+
}
109+
};
110+
111+
try {
112+
await walk(resolved.fullPath, baseRelativePath, 0);
113+
} catch (err) {
114+
return {
115+
status: 500,
116+
message: err instanceof Error ? err.message : String(err),
117+
};
118+
}
119+
120+
return {
121+
status: 200,
122+
payload: {
123+
path: baseRelativePath,
124+
recursive,
125+
maxDepth,
126+
entries,
127+
},
128+
};
129+
},
130+
});
131+
132+
function normalizeRelativePath(relativePath: string): string {
133+
if (!relativePath || relativePath === ".") return ".";
134+
return relativePath.replaceAll("\\", "/");
135+
}
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
+++
2+
label = "bot_list"
3+
execute = "../bot_list.deck.ts"
4+
+++
5+
6+
Compute-only deck for listing files and directories under the bot root.
Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
import * as path from "@std/path";
2+
import { defineDeck } from "jsr:@bolt-foundry/gambit";
3+
import { z } from "npm:zod";
4+
5+
const INTERNAL_KNOWLEDGE_ROOT = path.resolve(
6+
path.dirname(path.fromFileUrl(import.meta.url)),
7+
"../gambit-bot",
8+
);
9+
10+
export default defineDeck({
11+
label: "internal_knowledge_read",
12+
contextSchema: z.object({
13+
path: z.string().describe(
14+
"Relative file path under Gambit Bot internal knowledge root.",
15+
),
16+
}),
17+
responseSchema: z.object({
18+
status: z.number().optional(),
19+
message: z.string().optional(),
20+
payload: z.object({
21+
path: z.string(),
22+
contents: z.string(),
23+
}).optional(),
24+
}),
25+
async run(ctx) {
26+
let resolved;
27+
try {
28+
resolved = await resolveInternalKnowledgePath(ctx.input.path);
29+
} catch (err) {
30+
return {
31+
status: 400,
32+
message: err instanceof Error ? err.message : String(err),
33+
};
34+
}
35+
36+
try {
37+
const stat = await Deno.stat(resolved.fullPath);
38+
if (!stat.isFile) {
39+
return { status: 409, message: "path is not a file" };
40+
}
41+
const contents = await Deno.readTextFile(resolved.fullPath);
42+
return {
43+
status: 200,
44+
payload: { path: resolved.relativePath, contents },
45+
};
46+
} catch (err) {
47+
if (err instanceof Deno.errors.NotFound) {
48+
return { status: 404, message: "path not found" };
49+
}
50+
return {
51+
status: 500,
52+
message: err instanceof Error ? err.message : String(err),
53+
};
54+
}
55+
},
56+
});
57+
58+
async function resolveInternalKnowledgePath(inputPath: string): Promise<{
59+
fullPath: string;
60+
relativePath: string;
61+
}> {
62+
if (!inputPath || typeof inputPath !== "string") {
63+
throw new Error("path is required");
64+
}
65+
66+
const root = await Deno.realPath(INTERNAL_KNOWLEDGE_ROOT);
67+
const normalizedInput = path.normalize(inputPath);
68+
const segments = normalizedInput.split(/\\|\//g);
69+
if (segments.includes("..")) {
70+
throw new Error("path traversal is not allowed");
71+
}
72+
73+
const candidate = path.isAbsolute(normalizedInput)
74+
? normalizedInput
75+
: path.resolve(root, normalizedInput);
76+
const relativePath = path.relative(root, candidate);
77+
if (relativePath.startsWith("..") || path.isAbsolute(relativePath)) {
78+
throw new Error("path escapes internal knowledge root");
79+
}
80+
81+
let candidateStat: Deno.FileInfo | null = null;
82+
try {
83+
const stat = await Deno.lstat(candidate);
84+
candidateStat = stat;
85+
if (stat.isSymlink) {
86+
throw new Error("symlinks are not allowed");
87+
}
88+
89+
const realCandidate = await Deno.realPath(candidate);
90+
const realRelative = path.relative(root, realCandidate);
91+
if (realRelative.startsWith("..") || path.isAbsolute(realRelative)) {
92+
throw new Error("path escapes internal knowledge root");
93+
}
94+
} catch (err) {
95+
if (!(err instanceof Deno.errors.NotFound)) {
96+
throw err;
97+
}
98+
}
99+
100+
const parentAnchor = candidateStat?.isDirectory
101+
? candidate
102+
: path.dirname(candidate);
103+
const parent = await resolveExistingParent(parentAnchor);
104+
const parentReal = await Deno.realPath(parent);
105+
const parentRelative = path.relative(root, parentReal);
106+
if (parentRelative.startsWith("..") || path.isAbsolute(parentRelative)) {
107+
throw new Error("path escapes internal knowledge root");
108+
}
109+
110+
return {
111+
fullPath: candidate,
112+
relativePath: relativePath.replaceAll("\\", "/"),
113+
};
114+
}
115+
116+
async function resolveExistingParent(dir: string): Promise<string> {
117+
let current = dir;
118+
while (true) {
119+
try {
120+
const stat = await Deno.stat(current);
121+
if (!stat.isDirectory) {
122+
throw new Error("parent path is not a directory");
123+
}
124+
return current;
125+
} catch (err) {
126+
if (!(err instanceof Deno.errors.NotFound)) {
127+
throw err;
128+
}
129+
}
130+
131+
const next = path.dirname(current);
132+
if (next === current) {
133+
return current;
134+
}
135+
current = next;
136+
}
137+
}

0 commit comments

Comments
 (0)