@playcraft/cli 0.1.0 → 0.1.1
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/README.md +14 -2
- package/dist/utils/agent-api-client.js +50 -7
- package/dist/utils/tool-operation-journal.js +82 -16
- package/dist/workspace-runtime/codex/app-server.js +89 -48
- package/dist/workspace-runtime/codex/launcher-client.js +155 -0
- package/dist/workspace-runtime/codex/loopback.js +32 -2
- package/dist/workspace-runtime/serve.js +8 -0
- package/dist/workspace-runtime/server/dispatch.js +1 -0
- package/dist/workspace-runtime/server/http.js +65 -2
- package/dist/workspace-runtime/workspaces/git-credential-broker.js +276 -0
- package/dist/workspace-runtime/workspaces/git-credential-helper.js +66 -0
- package/dist/workspace-runtime/workspaces/git-direct-store.js +154 -0
- package/dist/workspace-runtime/workspaces/git.js +16 -3
- package/dist/workspace-runtime/workspaces/prepare.js +29 -172
- package/dist/workspace-runtime/workspaces/registry.js +12 -2
- package/package.json +4 -4
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import { constants } from 'node:fs';
|
|
2
|
+
import { lstat, mkdir, open, realpath, readdir, rename, unlink } from 'node:fs/promises';
|
|
3
|
+
import { randomUUID } from 'node:crypto';
|
|
4
|
+
import { join, resolve, sep } from 'node:path';
|
|
5
|
+
import { isLocalWorkspaceGitEnvironment, workspaceGitDirectAccessSchema } from '@playcraft/common/cloud-workspace/runtime';
|
|
6
|
+
/** Control-owned files only. Same-user processes are not an operating-system isolation boundary. */
|
|
7
|
+
export class WorkspaceGitDirectStore {
|
|
8
|
+
state;
|
|
9
|
+
mutation = Promise.resolve();
|
|
10
|
+
constructor(state) {
|
|
11
|
+
this.state = state;
|
|
12
|
+
}
|
|
13
|
+
exclusive(action) {
|
|
14
|
+
const result = this.mutation.then(action);
|
|
15
|
+
this.mutation = result.catch(() => undefined);
|
|
16
|
+
return result;
|
|
17
|
+
}
|
|
18
|
+
identity() {
|
|
19
|
+
const c = this.state.config;
|
|
20
|
+
return JSON.stringify([c.environment, c.userId, c.runtimeId, c.generation]);
|
|
21
|
+
}
|
|
22
|
+
name(id) {
|
|
23
|
+
if (!/^[a-f0-9-]{36}$/.test(id))
|
|
24
|
+
throw new Error('git_access_denied');
|
|
25
|
+
return `${id}.json`;
|
|
26
|
+
}
|
|
27
|
+
async directory(create, action) {
|
|
28
|
+
const stateDir = resolve(this.state.config.stateDir);
|
|
29
|
+
const directory = join(stateDir, 'direct-git');
|
|
30
|
+
if (!create && !(await lstat(directory).catch(error => {
|
|
31
|
+
if (error.code === 'ENOENT')
|
|
32
|
+
return undefined;
|
|
33
|
+
throw error;
|
|
34
|
+
})))
|
|
35
|
+
return undefined;
|
|
36
|
+
const root = await realpath(this.state.config.workspaceRoot);
|
|
37
|
+
if (this.state.config.stateDir !== stateDir || await realpath(stateDir) !== stateDir || stateDir === root || stateDir.startsWith(`${root}${sep}`)) {
|
|
38
|
+
throw new Error('git_control_directory_rejected');
|
|
39
|
+
}
|
|
40
|
+
for (const path of [stateDir, directory]) {
|
|
41
|
+
if (path === directory && create)
|
|
42
|
+
await mkdir(path, { mode: 0o700 }).catch(error => {
|
|
43
|
+
if (error.code !== 'EEXIST')
|
|
44
|
+
throw error;
|
|
45
|
+
});
|
|
46
|
+
const stat = await lstat(path);
|
|
47
|
+
if (!stat.isDirectory() || stat.isSymbolicLink() || stat.uid !== process.getuid?.() || (stat.mode & 0o077) !== 0) {
|
|
48
|
+
throw new Error('git_control_directory_rejected');
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
const handle = await open(directory, constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW);
|
|
52
|
+
try {
|
|
53
|
+
const pinned = process.platform === 'linux' ? `/proc/self/fd/${handle.fd}` : directory;
|
|
54
|
+
return await action(pinned);
|
|
55
|
+
}
|
|
56
|
+
finally {
|
|
57
|
+
await handle.close();
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
async read(path) {
|
|
61
|
+
const handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW).catch(error => {
|
|
62
|
+
if (error.code === 'ENOENT')
|
|
63
|
+
return undefined;
|
|
64
|
+
throw error;
|
|
65
|
+
});
|
|
66
|
+
if (!handle)
|
|
67
|
+
return undefined;
|
|
68
|
+
try {
|
|
69
|
+
const stat = await handle.stat();
|
|
70
|
+
if (!stat.isFile() || stat.uid !== process.getuid?.() || (stat.mode & 0o777) !== 0o600 || stat.nlink !== 1 || stat.size > 16384) {
|
|
71
|
+
throw new Error('git_credential_file_rejected');
|
|
72
|
+
}
|
|
73
|
+
return JSON.parse(await handle.readFile('utf8'));
|
|
74
|
+
}
|
|
75
|
+
finally {
|
|
76
|
+
await handle.close();
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
async get(binding) {
|
|
80
|
+
return this.directory(false, async (path) => {
|
|
81
|
+
const stored = await this.read(join(path, this.name(binding.workspaceId)));
|
|
82
|
+
if (!stored)
|
|
83
|
+
return undefined;
|
|
84
|
+
if (stored.identity !== this.identity() || JSON.stringify(stored.binding) !== JSON.stringify(binding)
|
|
85
|
+
|| !isLocalWorkspaceGitEnvironment(this.state.config.environment))
|
|
86
|
+
throw new Error('git_access_denied');
|
|
87
|
+
if (stored.access !== null) {
|
|
88
|
+
const access = workspaceGitDirectAccessSchema.parse(stored.access);
|
|
89
|
+
if (access.repositoryUrl !== binding.repositoryUrl)
|
|
90
|
+
throw new Error('git_access_denied');
|
|
91
|
+
}
|
|
92
|
+
return stored;
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
async put(binding, access, rejectedPassword) {
|
|
96
|
+
if (!isLocalWorkspaceGitEnvironment(this.state.config.environment) || this.state.journal.destroyed())
|
|
97
|
+
throw new Error('git_access_denied');
|
|
98
|
+
if (access && (workspaceGitDirectAccessSchema.parse(access).repositoryUrl !== binding.repositoryUrl))
|
|
99
|
+
throw new Error('git_access_denied');
|
|
100
|
+
await this.exclusive(() => this.directory(true, async (path) => {
|
|
101
|
+
const target = join(path, this.name(binding.workspaceId));
|
|
102
|
+
// Refuse symlink/hardlink/permissive old files even though rename would replace them.
|
|
103
|
+
const old = await this.read(target);
|
|
104
|
+
if (rejectedPassword !== undefined && old?.access?.password !== rejectedPassword)
|
|
105
|
+
return;
|
|
106
|
+
const temporary = join(path, `${randomUUID()}.tmp`);
|
|
107
|
+
const handle = await open(temporary, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
|
|
108
|
+
try {
|
|
109
|
+
await handle.writeFile(JSON.stringify({ identity: this.identity(), binding, access }));
|
|
110
|
+
await handle.sync();
|
|
111
|
+
await handle.close();
|
|
112
|
+
if (this.state.journal.destroyed())
|
|
113
|
+
throw new Error('git_access_denied');
|
|
114
|
+
await rename(temporary, target);
|
|
115
|
+
const dir = await open(path, constants.O_RDONLY | constants.O_DIRECTORY);
|
|
116
|
+
try {
|
|
117
|
+
await dir.sync();
|
|
118
|
+
}
|
|
119
|
+
finally {
|
|
120
|
+
await dir.close();
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
finally {
|
|
124
|
+
await handle.close();
|
|
125
|
+
await unlink(temporary).catch(error => { if (error.code !== 'ENOENT')
|
|
126
|
+
throw error; });
|
|
127
|
+
}
|
|
128
|
+
}));
|
|
129
|
+
}
|
|
130
|
+
async remove(workspaceId) {
|
|
131
|
+
await this.exclusive(() => this.directory(false, async (path) => {
|
|
132
|
+
const target = join(path, this.name(workspaceId));
|
|
133
|
+
await this.read(target);
|
|
134
|
+
await unlink(target).catch(error => { if (error.code !== 'ENOENT')
|
|
135
|
+
throw error; });
|
|
136
|
+
}));
|
|
137
|
+
}
|
|
138
|
+
async sweep(destroy = false) {
|
|
139
|
+
await this.exclusive(() => this.directory(false, async (path) => {
|
|
140
|
+
for (const name of await readdir(path)) {
|
|
141
|
+
if (!/^[a-f0-9-]{36}\.(json|tmp)$/.test(name))
|
|
142
|
+
throw new Error('git_credential_file_rejected');
|
|
143
|
+
const target = join(path, name);
|
|
144
|
+
if (name.endsWith('.tmp')) {
|
|
145
|
+
await unlink(target);
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
const stored = await this.read(target);
|
|
149
|
+
if (destroy || stored?.identity !== this.identity() || !isLocalWorkspaceGitEnvironment(this.state.config.environment))
|
|
150
|
+
await unlink(target);
|
|
151
|
+
}
|
|
152
|
+
}));
|
|
153
|
+
}
|
|
154
|
+
}
|
|
@@ -116,10 +116,23 @@ export async function pushWorkspaceGit(root) {
|
|
|
116
116
|
if (origin.code !== 0) {
|
|
117
117
|
throw new GitWorkspaceError('remote_not_configured', 'origin');
|
|
118
118
|
}
|
|
119
|
-
const
|
|
120
|
-
if (
|
|
121
|
-
throw new GitWorkspaceError('git_command_failed',
|
|
119
|
+
const ref = (await git(rootReal, ['symbolic-ref', '--quiet', 'HEAD'])).trim();
|
|
120
|
+
if (!ref.startsWith('refs/heads/'))
|
|
121
|
+
throw new GitWorkspaceError('git_command_failed', 'git_branch_required');
|
|
122
|
+
const commit = (await git(rootReal, ['rev-parse', '--verify', 'HEAD^{commit}'])).trim();
|
|
123
|
+
try {
|
|
124
|
+
const pushed = await gitAllowFail(rootReal, ['push', '--porcelain', 'origin', `${commit}:${ref}`], 90000);
|
|
125
|
+
if (pushed.code === 0)
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
catch { /* A lost response does not prove the remote rejected the update. */ }
|
|
129
|
+
try {
|
|
130
|
+
const remote = await gitAllowFail(rootReal, ['ls-remote', '--refs', 'origin', ref], 20000);
|
|
131
|
+
if (remote.code === 0 && remote.stdout.trim() === `${commit}\t${ref}`)
|
|
132
|
+
return;
|
|
122
133
|
}
|
|
134
|
+
catch { /* Preserve uncertainty; never replay a write automatically. */ }
|
|
135
|
+
throw new GitWorkspaceError('git_push_outcome_unknown', 'git_push_outcome_unknown');
|
|
123
136
|
}
|
|
124
137
|
export async function ensureWorkspaceGit(root) {
|
|
125
138
|
const rootReal = await realpath(root);
|
|
@@ -1,9 +1,8 @@
|
|
|
1
1
|
import { spawn } from 'node:child_process';
|
|
2
|
-
import { randomUUID
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import { matchWorkspaceGitGatewayCloneUrl, workspaceGitAccessSchema, workspaceGitTransportSchema, workspacePreparationSchema, workspaceRegistrationSchema, } from '@playcraft/common/cloud-workspace/runtime';
|
|
2
|
+
import { randomUUID } from 'node:crypto';
|
|
3
|
+
import { lstat, mkdir, readFile, realpath, rename, rm, writeFile } from 'node:fs/promises';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { isLocalWorkspaceGitAuthMode, isLocalWorkspaceGitEnvironment, matchWorkspaceGitGatewayCloneUrl, workspacePreparationSchema, workspaceRegistrationSchema, } from '@playcraft/common/cloud-workspace/runtime';
|
|
7
6
|
import { WorkspaceContextError } from './context-error.js';
|
|
8
7
|
/** The caller holds the Workspace lock. Only an absent, never-published checkout may be cloned. */
|
|
9
8
|
export async function prepareWorkspaceCheckout(state, binding, input, leaseFence) {
|
|
@@ -11,6 +10,10 @@ export async function prepareWorkspaceCheckout(state, binding, input, leaseFence
|
|
|
11
10
|
if (!preparation.success)
|
|
12
11
|
throw new WorkspaceContextError('invalid_workspace_preparation');
|
|
13
12
|
const source = parseCloneSource(binding.repositoryUrl);
|
|
13
|
+
const direct = isLocalWorkspaceGitAuthMode(preparation.data.authMode);
|
|
14
|
+
if (direct && (!isLocalWorkspaceGitEnvironment(state.config.environment) || !preparation.data.directAccess
|
|
15
|
+
|| preparation.data.directAccess.repositoryUrl !== binding.repositoryUrl))
|
|
16
|
+
throw new WorkspaceContextError('workspace_git_gateway_unavailable');
|
|
14
17
|
const parent = await realpath(state.config.workspaceRoot);
|
|
15
18
|
const target = join(parent, binding.workspaceId);
|
|
16
19
|
const receiptDir = join(state.config.stateDir, 'workspace-preparations');
|
|
@@ -40,19 +43,17 @@ export async function prepareWorkspaceCheckout(state, binding, input, leaseFence
|
|
|
40
43
|
await rm(staging, { recursive: true, force: true });
|
|
41
44
|
await mkdir(staging, { mode: 0o700 });
|
|
42
45
|
const access = preparation.data.gitAccess;
|
|
46
|
+
const credential = preparation.data.directAccess ?? access;
|
|
43
47
|
const now = Date.now();
|
|
44
48
|
if (access &&
|
|
45
|
-
(Date.parse(access.issuedAt) > now || Date.parse(access.expiresAt) <= now + 10_000))
|
|
49
|
+
(Date.parse(access.issuedAt) > now || Date.parse(access.expiresAt) <= now + 10_000)) {
|
|
50
|
+
await rm(staging, { recursive: true, force: true });
|
|
46
51
|
throw new WorkspaceContextError('workspace_git_access_expired');
|
|
47
|
-
let gateway;
|
|
48
|
-
try {
|
|
49
|
-
gateway = access
|
|
50
|
-
? await mountGateway(state, binding.repositoryUrl, access, preparation.data.transport)
|
|
51
|
-
: undefined;
|
|
52
52
|
}
|
|
53
|
-
|
|
53
|
+
const transport = preparation.data.transport;
|
|
54
|
+
if (transport && !matchWorkspaceGitGatewayCloneUrl(transport.cloneUrl, state.config.gitGatewayOrigins, binding.repositoryUrl).ok) {
|
|
54
55
|
await rm(staging, { recursive: true, force: true });
|
|
55
|
-
throw
|
|
56
|
+
throw new WorkspaceContextError('workspace_git_gateway_unavailable');
|
|
56
57
|
}
|
|
57
58
|
const deadline = Math.min(now + 90_000, access ? Date.parse(access.expiresAt) : Infinity);
|
|
58
59
|
const env = {
|
|
@@ -63,23 +64,16 @@ export async function prepareWorkspaceCheckout(state, binding, input, leaseFence
|
|
|
63
64
|
GIT_TERMINAL_PROMPT: '0',
|
|
64
65
|
GIT_ALLOW_PROTOCOL: 'http:https',
|
|
65
66
|
};
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
env.GIT_CONFIG_COUNT = '1';
|
|
70
|
-
env.GIT_CONFIG_KEY_0 = `
|
|
71
|
-
env.GIT_CONFIG_VALUE_0 =
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
cloneConfig.push(`credential.helper=!${shellQuote(process.execPath)} -e ${shellQuote('eval(Buffer.from(process.env.PLAYCRAFT_GIT_HELPER,"base64").toString())')}`, 'credential.useHttpPath=true');
|
|
77
|
-
}
|
|
78
|
-
else if (access) {
|
|
79
|
-
// Authentication exists only in the clone process environment; never in arguments or .git/config.
|
|
80
|
-
env.GIT_CONFIG_COUNT = '1';
|
|
81
|
-
env.GIT_CONFIG_KEY_0 = `http.${source.href}.extraHeader`;
|
|
82
|
-
env.GIT_CONFIG_VALUE_0 = `Authorization: Basic ${Buffer.from(`${access.username}:${access.password}`).toString('base64')}`;
|
|
67
|
+
// The authenticated registration already carries this operation's scoped credential.
|
|
68
|
+
// No publisher ledger is mounted in the sandbox; secrets exist only in this child environment.
|
|
69
|
+
if (credential) {
|
|
70
|
+
env.GIT_CONFIG_COUNT = transport ? '2' : '1';
|
|
71
|
+
env.GIT_CONFIG_KEY_0 = `http.${transport?.cloneUrl ?? source.href}.extraHeader`;
|
|
72
|
+
env.GIT_CONFIG_VALUE_0 = `Authorization: Basic ${Buffer.from(`${credential.username}:${credential.password}`).toString('base64')}`;
|
|
73
|
+
if (transport) {
|
|
74
|
+
env.GIT_CONFIG_KEY_1 = `url.${transport.cloneUrl}.insteadOf`;
|
|
75
|
+
env.GIT_CONFIG_VALUE_1 = binding.repositoryUrl;
|
|
76
|
+
}
|
|
83
77
|
}
|
|
84
78
|
try {
|
|
85
79
|
await runGit(['check-ref-format', '--branch', binding.branchName], env, deadline);
|
|
@@ -92,7 +86,7 @@ export async function prepareWorkspaceCheckout(state, binding, input, leaseFence
|
|
|
92
86
|
'--',
|
|
93
87
|
binding.repositoryUrl,
|
|
94
88
|
staging,
|
|
95
|
-
], env, deadline
|
|
89
|
+
], env, deadline);
|
|
96
90
|
// Checkout never receives the temporary credential and never invokes repository hooks or global filters.
|
|
97
91
|
clearCloneSecrets(env);
|
|
98
92
|
await runGit(['-C', staging, 'checkout', '--force', binding.branchName], env, deadline);
|
|
@@ -106,6 +100,9 @@ export async function prepareWorkspaceCheckout(state, binding, input, leaseFence
|
|
|
106
100
|
throw error;
|
|
107
101
|
throw new WorkspaceContextError('workspace_clone_failed');
|
|
108
102
|
}
|
|
103
|
+
finally {
|
|
104
|
+
clearCloneSecrets(env);
|
|
105
|
+
}
|
|
109
106
|
}
|
|
110
107
|
const stat = await lstat(staging).catch(() => undefined);
|
|
111
108
|
if (!stat?.isDirectory() || stat.isSymbolicLink())
|
|
@@ -124,149 +121,10 @@ function clearCloneSecrets(env) {
|
|
|
124
121
|
'GIT_CONFIG_VALUE_0',
|
|
125
122
|
'GIT_CONFIG_KEY_1',
|
|
126
123
|
'GIT_CONFIG_VALUE_1',
|
|
127
|
-
'PLAYCRAFT_GIT_HELPER',
|
|
128
|
-
'PLAYCRAFT_GIT_CREDENTIAL_DIRECTORY',
|
|
129
|
-
'PLAYCRAFT_GIT_GATEWAY_CLONE_URL',
|
|
130
|
-
'PLAYCRAFT_GIT_CANONICAL_URL',
|
|
131
124
|
]) {
|
|
132
125
|
delete env[key];
|
|
133
126
|
}
|
|
134
127
|
}
|
|
135
|
-
function shellQuote(value) {
|
|
136
|
-
return `'${value.replaceAll("'", "'\\''")}'`;
|
|
137
|
-
}
|
|
138
|
-
/** Read-only open of the publisher's control directory. Credentials are not copied into the checkout. */
|
|
139
|
-
async function mountGateway(state, repositoryUrl, access, transport) {
|
|
140
|
-
if (!transport)
|
|
141
|
-
return undefined;
|
|
142
|
-
const directory = state.config.gitCredentialDirectory;
|
|
143
|
-
const matched = matchWorkspaceGitGatewayCloneUrl(transport.cloneUrl, state.config.gitGatewayOrigins, repositoryUrl);
|
|
144
|
-
if (!matched.ok || !directory)
|
|
145
|
-
throw new WorkspaceContextError('workspace_git_gateway_unavailable');
|
|
146
|
-
const parsed = workspaceGitTransportSchema.parse(transport);
|
|
147
|
-
if (parsed.cloneUrl !== transport.cloneUrl) {
|
|
148
|
-
throw new WorkspaceContextError('workspace_git_gateway_unavailable');
|
|
149
|
-
}
|
|
150
|
-
const entry = await readMountedEntry(directory, transport.cloneUrl, matched.requestId, repositoryUrl);
|
|
151
|
-
if (entry.username !== access.username ||
|
|
152
|
-
entry.issuedAt !== access.issuedAt ||
|
|
153
|
-
entry.expiresAt !== access.expiresAt ||
|
|
154
|
-
!sameSecret(entry.password, access.password)) {
|
|
155
|
-
throw new WorkspaceContextError('workspace_git_gateway_unavailable');
|
|
156
|
-
}
|
|
157
|
-
return { directory, cloneUrl: transport.cloneUrl };
|
|
158
|
-
}
|
|
159
|
-
async function readMountedEntry(directory, cloneUrl, requestId, repositoryUrl) {
|
|
160
|
-
if (!isAbsolute(directory) || (await realpath(directory)) !== resolve(directory)) {
|
|
161
|
-
throw new WorkspaceContextError('workspace_git_gateway_unavailable');
|
|
162
|
-
}
|
|
163
|
-
const directoryStat = await lstat(directory);
|
|
164
|
-
if (!directoryStat.isDirectory() ||
|
|
165
|
-
directoryStat.isSymbolicLink() ||
|
|
166
|
-
(directoryStat.mode & 0o077) !== 0 ||
|
|
167
|
-
(typeof process.getuid === 'function' && directoryStat.uid !== process.getuid())) {
|
|
168
|
-
throw new WorkspaceContextError('workspace_git_gateway_unavailable');
|
|
169
|
-
}
|
|
170
|
-
const directoryHandle = await open(directory, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_DIRECTORY);
|
|
171
|
-
await directoryHandle.close();
|
|
172
|
-
const file = await open(join(directory, 'workspace-git-credentials.json'), constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
173
|
-
try {
|
|
174
|
-
const stat = await file.stat();
|
|
175
|
-
if (!stat.isFile() || (stat.mode & 0o077) !== 0 || stat.size > 1024 * 1024) {
|
|
176
|
-
throw new WorkspaceContextError('workspace_git_gateway_unavailable');
|
|
177
|
-
}
|
|
178
|
-
const document = JSON.parse(await file.readFile('utf8'));
|
|
179
|
-
if (blocked(document).has(requestId)) {
|
|
180
|
-
throw new WorkspaceContextError('workspace_git_gateway_unavailable');
|
|
181
|
-
}
|
|
182
|
-
const entries = (document.entries ?? []).filter((entry) => entry.transport &&
|
|
183
|
-
typeof entry.transport === 'object' &&
|
|
184
|
-
entry.transport.cloneUrl === cloneUrl);
|
|
185
|
-
if (entries.length !== 1)
|
|
186
|
-
throw new WorkspaceContextError('workspace_git_gateway_unavailable');
|
|
187
|
-
const entry = entries[0];
|
|
188
|
-
if (entry.repositoryUrl !== repositoryUrl ||
|
|
189
|
-
requestIdOf(entry.command) !== requestId ||
|
|
190
|
-
requestIdOf(entry.receipt) !== requestId) {
|
|
191
|
-
throw new WorkspaceContextError('workspace_git_gateway_unavailable');
|
|
192
|
-
}
|
|
193
|
-
return workspaceGitAccessSchema.parse(entry.gitAccess);
|
|
194
|
-
}
|
|
195
|
-
finally {
|
|
196
|
-
await file.close();
|
|
197
|
-
}
|
|
198
|
-
}
|
|
199
|
-
function requestIdOf(value) {
|
|
200
|
-
if (!value || typeof value !== 'object')
|
|
201
|
-
return undefined;
|
|
202
|
-
const id = value.requestId;
|
|
203
|
-
return typeof id === 'string' ? id : undefined;
|
|
204
|
-
}
|
|
205
|
-
function blocked(document) {
|
|
206
|
-
const ids = new Set();
|
|
207
|
-
for (const list of [document.retired, document.revocations]) {
|
|
208
|
-
if (list === undefined)
|
|
209
|
-
continue;
|
|
210
|
-
if (!Array.isArray(list))
|
|
211
|
-
throw new WorkspaceContextError('workspace_git_gateway_unavailable');
|
|
212
|
-
for (const item of list) {
|
|
213
|
-
const id = requestIdOf(item);
|
|
214
|
-
if (id)
|
|
215
|
-
ids.add(id);
|
|
216
|
-
}
|
|
217
|
-
}
|
|
218
|
-
return ids;
|
|
219
|
-
}
|
|
220
|
-
function sameSecret(left, right) {
|
|
221
|
-
const a = Buffer.from(left);
|
|
222
|
-
const b = Buffer.from(right);
|
|
223
|
-
return a.length === b.length && timingSafeEqual(a, b);
|
|
224
|
-
}
|
|
225
|
-
const GIT_GATEWAY_CREDENTIAL_HELPER = `
|
|
226
|
-
const fs = require('node:fs');
|
|
227
|
-
const path = require('node:path');
|
|
228
|
-
const action = process.argv[1];
|
|
229
|
-
if (action !== 'get') process.exit(0);
|
|
230
|
-
const chunks = [];
|
|
231
|
-
process.stdin.on('data', (chunk) => chunks.push(chunk));
|
|
232
|
-
process.stdin.on('end', () => {
|
|
233
|
-
try {
|
|
234
|
-
const fields = {};
|
|
235
|
-
for (const line of Buffer.concat(chunks).toString('utf8').split('\\n')) {
|
|
236
|
-
if (!line) continue;
|
|
237
|
-
const index = line.indexOf('=');
|
|
238
|
-
if (index > 0) fields[line.slice(0, index)] = line.slice(index + 1);
|
|
239
|
-
}
|
|
240
|
-
const cloneUrl = new URL(process.env.PLAYCRAFT_GIT_GATEWAY_CLONE_URL);
|
|
241
|
-
const canonical = new URL(process.env.PLAYCRAFT_GIT_CANONICAL_URL);
|
|
242
|
-
if (!fields.path || !fields.protocol || !fields.host) process.exit(1);
|
|
243
|
-
const requested = new URL(fields.protocol + '://' + fields.host + '/' + String(fields.path).replace(/^\\/+/, ''));
|
|
244
|
-
if (requested.origin !== cloneUrl.origin || requested.pathname !== cloneUrl.pathname || requested.origin === canonical.origin) process.exit(1);
|
|
245
|
-
const requestId = cloneUrl.pathname.split('/').pop();
|
|
246
|
-
const directory = process.env.PLAYCRAFT_GIT_CREDENTIAL_DIRECTORY;
|
|
247
|
-
const flags = fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW;
|
|
248
|
-
const dir = fs.openSync(directory, flags | (fs.constants.O_DIRECTORY || 0));
|
|
249
|
-
try {
|
|
250
|
-
if (!fs.fstatSync(dir).isDirectory()) process.exit(1);
|
|
251
|
-
} finally { fs.closeSync(dir); }
|
|
252
|
-
const file = fs.openSync(path.join(directory, 'workspace-git-credentials.json'), flags);
|
|
253
|
-
try {
|
|
254
|
-
const stat = fs.fstatSync(file);
|
|
255
|
-
if (!stat.isFile() || (stat.mode & 0o077) !== 0 || stat.size > 1024 * 1024) process.exit(1);
|
|
256
|
-
const document = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
257
|
-
const blocked = [...(document.retired || []), ...(document.revocations || [])];
|
|
258
|
-
if (blocked.some((item) => item && item.requestId === requestId)) process.exit(1);
|
|
259
|
-
const entries = (document.entries || []).filter((entry) => entry && entry.transport && entry.transport.cloneUrl === process.env.PLAYCRAFT_GIT_GATEWAY_CLONE_URL && entry.repositoryUrl === process.env.PLAYCRAFT_GIT_CANONICAL_URL);
|
|
260
|
-
if (entries.length !== 1) process.exit(1);
|
|
261
|
-
const access = entries[0].gitAccess || {};
|
|
262
|
-
if (Date.parse(access.issuedAt) > Date.now() || Date.parse(access.expiresAt) <= Date.now() + 10000) process.exit(1);
|
|
263
|
-
if (!access.username || !access.password || /[\\r\\n\\0]/.test(access.username) || /[\\r\\n\\0]/.test(access.password)) process.exit(1);
|
|
264
|
-
process.stdout.write('username=' + access.username + '\\npassword=' + access.password + '\\n');
|
|
265
|
-
process.exit(0);
|
|
266
|
-
} finally { fs.closeSync(file); }
|
|
267
|
-
} catch { process.exit(1); }
|
|
268
|
-
});
|
|
269
|
-
`;
|
|
270
128
|
function parseCloneSource(value) {
|
|
271
129
|
try {
|
|
272
130
|
const source = new URL(value);
|
|
@@ -316,7 +174,7 @@ async function writeReceipt(path, value) {
|
|
|
316
174
|
await writeFile(temp, `${JSON.stringify(value)}\n`, { mode: 0o600 });
|
|
317
175
|
await rename(temp, path);
|
|
318
176
|
}
|
|
319
|
-
function runGit(args, env, deadline
|
|
177
|
+
function runGit(args, env, deadline) {
|
|
320
178
|
return new Promise((resolve, reject) => {
|
|
321
179
|
let timedOut = false;
|
|
322
180
|
const child = spawn('git', [
|
|
@@ -324,7 +182,6 @@ function runGit(args, env, deadline, config = []) {
|
|
|
324
182
|
'core.hooksPath=/dev/null',
|
|
325
183
|
'-c',
|
|
326
184
|
'credential.helper=',
|
|
327
|
-
...config.flatMap((item) => ['-c', item]),
|
|
328
185
|
'-c',
|
|
329
186
|
'http.followRedirects=false',
|
|
330
187
|
'-c',
|
|
@@ -2,7 +2,7 @@ import { execFile } from 'node:child_process';
|
|
|
2
2
|
import { lstat, realpath } from 'node:fs/promises';
|
|
3
3
|
import { join, relative } from 'node:path';
|
|
4
4
|
import { promisify } from 'node:util';
|
|
5
|
-
import { workspaceRegistrationSchema, } from '@playcraft/common/cloud-workspace/runtime';
|
|
5
|
+
import { isLocalWorkspaceGitAuthMode, workspaceRegistrationSchema, } from '@playcraft/common/cloud-workspace/runtime';
|
|
6
6
|
import { prepareWorkspaceCheckout } from './prepare.js';
|
|
7
7
|
import { WorkspaceContextError } from './context-error.js';
|
|
8
8
|
export { WorkspaceContextError } from './context-error.js';
|
|
@@ -87,7 +87,7 @@ export async function registerWorkspace(state, input, leaseFence, preparation) {
|
|
|
87
87
|
})).stdout.trim();
|
|
88
88
|
try {
|
|
89
89
|
if ((await realpath(await git('rev-parse', '--show-toplevel'))) !== root ||
|
|
90
|
-
(await git('
|
|
90
|
+
(await git('config', '--get', 'remote.origin.url')) !== binding.repositoryUrl ||
|
|
91
91
|
(await git('symbolic-ref', '--short', 'HEAD')) !== binding.branchName) {
|
|
92
92
|
throw new WorkspaceContextError('workspace_checkout_mismatch');
|
|
93
93
|
}
|
|
@@ -101,6 +101,16 @@ export async function registerWorkspace(state, input, leaseFence, preparation) {
|
|
|
101
101
|
throw new WorkspaceContextError('runtime_destroyed');
|
|
102
102
|
if (leaseFence < state.acceptedLeaseFence)
|
|
103
103
|
throw new WorkspaceContextError('stale_lease');
|
|
104
|
+
if (preparation?.transport?.kind === 'git-gateway-v2' || isLocalWorkspaceGitAuthMode(preparation?.authMode)) {
|
|
105
|
+
if (!state.gitCredentials)
|
|
106
|
+
throw new WorkspaceContextError('workspace_git_gateway_unavailable');
|
|
107
|
+
try {
|
|
108
|
+
await state.gitCredentials.configure(binding, preparation);
|
|
109
|
+
}
|
|
110
|
+
catch {
|
|
111
|
+
throw new WorkspaceContextError('workspace_git_gateway_unavailable');
|
|
112
|
+
}
|
|
113
|
+
}
|
|
104
114
|
await state.journal.putWorkspace(binding);
|
|
105
115
|
return binding;
|
|
106
116
|
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@playcraft/cli",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.1",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"engines": {
|
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
"dev": "tsc -w",
|
|
25
25
|
"build": "tsc",
|
|
26
26
|
"start": "node dist/index.js",
|
|
27
|
-
"test": "vitest run",
|
|
27
|
+
"test": "pnpm build && vitest run",
|
|
28
28
|
"test:agent": "node scripts/run-agent-tests.mjs --list",
|
|
29
29
|
"test:agent:designer": "node scripts/run-agent-tests.mjs game-designer",
|
|
30
30
|
"test:agent:developer": "node scripts/run-agent-tests.mjs developer",
|
|
@@ -48,8 +48,8 @@
|
|
|
48
48
|
"@gltf-transform/core": "^4.3.0",
|
|
49
49
|
"@gltf-transform/extensions": "^4.3.0",
|
|
50
50
|
"@gltf-transform/functions": "^4.3.0",
|
|
51
|
-
"@playcraft/build": "^0.1.
|
|
52
|
-
"@playcraft/common": "^0.1.
|
|
51
|
+
"@playcraft/build": "^0.1.1",
|
|
52
|
+
"@playcraft/common": "^0.1.1",
|
|
53
53
|
"better-sqlite3-pinned": "npm:better-sqlite3@13.0.3",
|
|
54
54
|
"chokidar": "^4.0.3",
|
|
55
55
|
"commander": "^13.1.0",
|