@runuai/host 0.9.131 → 0.9.133
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/db/migrations/0023_project_env_org.sql +4 -0
- package/db/migrations/meta/_journal.json +7 -0
- package/db/schema.ts +5 -0
- package/lib/browser-testing.ts +7 -3
- package/lib/credential-store-scope.ts +35 -8
- package/lib/credential-store.ts +71 -0
- package/lib/host-env.ts +99 -4
- package/lib/shared-files-s3.ts +13 -4
- package/lib/sidecar-resume.ts +108 -0
- package/lib/skills.ts +33 -1
- package/lib/task-environment/machine-task-up.ts +1 -1
- package/lib/user-credentials-store.ts +30 -12
- package/package.json +1 -1
- package/src/index.ts +42 -0
- package/src/main.ts +25 -1
- package/src/protocol.ts +6 -1
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
-- ADR-133: the project's org, so the mirrored parameter can carry the `org`
|
|
2
|
+
-- tag the task-machine lease policy conditions on. Null until the cloud sends
|
|
3
|
+
-- it (env.var.set / env.var.list with orgId) or a stored value carries it.
|
|
4
|
+
ALTER TABLE `host_project_env` ADD `org_id` text;
|
package/db/schema.ts
CHANGED
|
@@ -188,6 +188,11 @@ export const hostProjectEnv = sqliteTable(
|
|
|
188
188
|
.default(sql`(unixepoch() * 1000)`),
|
|
189
189
|
// ADR-134 slice 3: the credential-store parameter this row mirrors to.
|
|
190
190
|
storeRef: text("store_ref"),
|
|
191
|
+
// The project's org, learned from an env.var.set/list frame that carries
|
|
192
|
+
// it (cloud from 2026-09-10) or from a stored value that does. It tags the
|
|
193
|
+
// mirrored parameter (`org`, `project`) so a task machine's tag-scoped
|
|
194
|
+
// lease can read it (ADR-133). Null until the cloud has said.
|
|
195
|
+
orgId: text("org_id"),
|
|
191
196
|
},
|
|
192
197
|
(t) => ({
|
|
193
198
|
pk: primaryKey({ columns: [t.projectId, t.key] }),
|
package/lib/browser-testing.ts
CHANGED
|
@@ -1035,9 +1035,13 @@ console.log(
|
|
|
1035
1035
|
*/
|
|
1036
1036
|
const VIEWER_STEPS: string[] = [
|
|
1037
1037
|
// Xvfb — one-shot; the X lockfile is the truth (also set when an agent
|
|
1038
|
-
// started the display itself). If it dies the lock clears and the
|
|
1039
|
-
// session ensure relaunches it.
|
|
1040
|
-
|
|
1038
|
+
// started the display itself). If it dies cleanly the lock clears and the
|
|
1039
|
+
// next session ensure relaunches it. A lock whose pid is DEAD is the
|
|
1040
|
+
// leftover of a SIGKILLed server — a sidecar restart kills its whole
|
|
1041
|
+
// cgroup (live 2026-09-10, smoke 10: Xvfb gone, lock present, x11vnc
|
|
1042
|
+
// looping on a display that never came back) — so clear it and the
|
|
1043
|
+
// socket first, then launch as usual.
|
|
1044
|
+
`[ -x /usr/bin/Xvfb ] || exit 0; if [ -e ${X_LOCK} ] && ! /usr/bin/kill -0 "$(/usr/bin/tr -d ' \\n' < ${X_LOCK})" 2>/dev/null; then /bin/rm -f ${X_LOCK} /tmp/.X11-unix/X${SERVER_DISPLAY.slice(1)}; fi; [ -e ${X_LOCK} ] || /usr/bin/nohup /usr/bin/${XVFB_CMD} >>/tmp/uai-xvfb.log 2>&1 &`,
|
|
1041
1045
|
// x11vnc under a restart loop (it exits whenever the X server isn't up
|
|
1042
1046
|
// yet). Pidfile-guarded.
|
|
1043
1047
|
`[ -x /usr/bin/x11vnc ] || exit 0; [ -f /tmp/uai-x11vnc.pid ] && /usr/bin/kill -0 "$(/usr/bin/cat /tmp/uai-x11vnc.pid)" 2>/dev/null && exit 0; /usr/bin/pkill -A -f 'x11vnc -display' 2>/dev/null; /usr/bin/nohup /bin/sh -c 'echo $$ > /tmp/uai-x11vnc.pid; while true; do /usr/bin/x11vnc -display ${SERVER_DISPLAY} -forever -shared -nopw -quiet >>/tmp/uai-x11vnc.log 2>&1; /bin/sleep 2; done' >/dev/null 2>&1 &`,
|
|
@@ -73,13 +73,40 @@ export function userCredentialListPath(prefix: string): string | null {
|
|
|
73
73
|
return `${prefix}/users/${scope.userId}`;
|
|
74
74
|
}
|
|
75
75
|
|
|
76
|
-
/**
|
|
77
|
-
|
|
76
|
+
/**
|
|
77
|
+
* The paths a project-env listing walks, or null to skip. An owned or shared
|
|
78
|
+
* host walks the whole tree. A task machine's lease reads
|
|
79
|
+
* `/uai/projects/<id>/…` only for parameters tagged with its org (the policy
|
|
80
|
+
* conditions on `ssm:ResourceTag/org`), and only the task's own projects are
|
|
81
|
+
* worth asking for — their ids arrive with task-up, not with the first grant,
|
|
82
|
+
* so a listing without ids on a task machine is simply "nothing yet".
|
|
83
|
+
*/
|
|
84
|
+
export function projectEnvListPaths(
|
|
85
|
+
prefix: string,
|
|
86
|
+
projectIds?: readonly string[],
|
|
87
|
+
): string[] | null {
|
|
78
88
|
const scope = storeListScope();
|
|
79
|
-
if (scope.kind === "host") return `${prefix}/projects
|
|
80
|
-
|
|
81
|
-
"project-env",
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
89
|
+
if (scope.kind === "host") return [`${prefix}/projects`];
|
|
90
|
+
if (scope.kind === "none") {
|
|
91
|
+
warnOnce("project-env", `not read: ${scope.reason}`);
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
if (!scope.orgId) {
|
|
95
|
+
warnOnce("project-env", "not read on a task machine whose lease carries no org");
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
if (!projectIds) return null;
|
|
99
|
+
return projectIds
|
|
100
|
+
.filter((id) => isParameterPathSegment(id))
|
|
101
|
+
.map((id) => `${prefix}/projects/${id}`);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Whether project env is read by name (describe + get-parameter) instead of
|
|
106
|
+
* by path: true under a lease, whose policy conditions on each parameter's
|
|
107
|
+
* `org` tag and therefore denies the path listing itself (see
|
|
108
|
+
* `CredentialStore.listByName`).
|
|
109
|
+
*/
|
|
110
|
+
export function projectEnvListsByName(): boolean {
|
|
111
|
+
return storeListScope().kind === "lease";
|
|
85
112
|
}
|
package/lib/credential-store.ts
CHANGED
|
@@ -46,6 +46,21 @@ export interface CredentialStore {
|
|
|
46
46
|
delete(name: string): Promise<void>;
|
|
47
47
|
/** Every credential under `path` (recursive), decrypted. Throws on failure. */
|
|
48
48
|
list(path: string): Promise<StoredParameter[]>;
|
|
49
|
+
/**
|
|
50
|
+
* Every credential under `path` read by NAME: names from
|
|
51
|
+
* `describe-parameters` (allowed on `*`, never returns values), then one
|
|
52
|
+
* `get-parameter` each, decrypted; a name the identity may not read is
|
|
53
|
+
* skipped. This is how a task machine's lease reads `/uai/projects/<id>`:
|
|
54
|
+
* its policy conditions on the parameter's `org` tag, and
|
|
55
|
+
* `get-parameters-by-path` is authorised against the PATH resource, which
|
|
56
|
+
* carries no tags, so it is denied even when every parameter under it is
|
|
57
|
+
* tagged (live 2026-09-10, smoke 12).
|
|
58
|
+
*/
|
|
59
|
+
listByName(path: string): Promise<StoredParameter[]>;
|
|
60
|
+
/** Set tags on an existing parameter (a second call, because put-parameter
|
|
61
|
+
* with Overwrite cannot carry tags). Throws on failure — including when
|
|
62
|
+
* the identity lacks `ssm:AddTagsToResource`, which older host policies do. */
|
|
63
|
+
tag(name: string, tags: Record<string, string>): Promise<void>;
|
|
49
64
|
/** Names and versions under `path` (recursive), NO decryption — the cheap
|
|
50
65
|
* poll a Uai-operated host runs to notice a broker refresh. */
|
|
51
66
|
listVersions(path: string): Promise<Array<{ name: string; version: number }>>;
|
|
@@ -136,6 +151,10 @@ function isNotFound(result: CliResult): boolean {
|
|
|
136
151
|
return /ParameterNotFound/.test(result.stderr);
|
|
137
152
|
}
|
|
138
153
|
|
|
154
|
+
function isAccessDenied(result: CliResult): boolean {
|
|
155
|
+
return /AccessDenied/.test(result.stderr);
|
|
156
|
+
}
|
|
157
|
+
|
|
139
158
|
/** Summarize a CLI failure WITHOUT echoing stdout (which could carry a value). */
|
|
140
159
|
function failure(op: string, result: CliResult): Error {
|
|
141
160
|
const line =
|
|
@@ -181,6 +200,28 @@ export class AwsSsmCredentialStore implements CredentialStore {
|
|
|
181
200
|
if (result.status !== 0) throw failure("put-parameter", result);
|
|
182
201
|
}
|
|
183
202
|
|
|
203
|
+
async tag(name: string, tags: Record<string, string>): Promise<void> {
|
|
204
|
+
if (!isParameterName(name)) throw new Error(`invalid parameter name: ${name}`);
|
|
205
|
+
const entries = Object.entries(tags);
|
|
206
|
+
if (entries.length === 0) return;
|
|
207
|
+
for (const [key, value] of entries) {
|
|
208
|
+
if (!/^[A-Za-z0-9_.:/=+@-]{1,128}$/.test(key) || !/^[A-Za-z0-9_.:/=+@ -]{0,256}$/.test(value)) {
|
|
209
|
+
throw new Error(`invalid tag ${key}`);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
const result = await this.run([
|
|
213
|
+
"ssm",
|
|
214
|
+
"add-tags-to-resource",
|
|
215
|
+
"--resource-type",
|
|
216
|
+
"Parameter",
|
|
217
|
+
"--resource-id",
|
|
218
|
+
name,
|
|
219
|
+
"--tags",
|
|
220
|
+
...entries.map(([key, value]) => `Key=${key},Value=${value}`),
|
|
221
|
+
]);
|
|
222
|
+
if (result.status !== 0) throw failure("add-tags-to-resource", result);
|
|
223
|
+
}
|
|
224
|
+
|
|
184
225
|
async get(name: string): Promise<string | null> {
|
|
185
226
|
if (!isParameterName(name)) throw new Error(`invalid parameter name: ${name}`);
|
|
186
227
|
const result = await this.run([
|
|
@@ -243,6 +284,36 @@ export class AwsSsmCredentialStore implements CredentialStore {
|
|
|
243
284
|
return out;
|
|
244
285
|
}
|
|
245
286
|
|
|
287
|
+
async listByName(path: string): Promise<StoredParameter[]> {
|
|
288
|
+
if (!isParameterName(path)) throw new Error(`invalid parameter path: ${path}`);
|
|
289
|
+
const described = await this.run([
|
|
290
|
+
"ssm",
|
|
291
|
+
"describe-parameters",
|
|
292
|
+
"--parameter-filters",
|
|
293
|
+
`Key=Path,Option=Recursive,Values=${path}`,
|
|
294
|
+
"--query",
|
|
295
|
+
"Parameters[].Name",
|
|
296
|
+
]);
|
|
297
|
+
if (described.status !== 0) throw failure("describe-parameters", described);
|
|
298
|
+
const parsed: unknown = JSON.parse(described.stdout || "[]");
|
|
299
|
+
const names = (Array.isArray(parsed) ? parsed : []).filter(
|
|
300
|
+
(name): name is string => typeof name === "string" && name.startsWith(`${path}/`),
|
|
301
|
+
);
|
|
302
|
+
const out: StoredParameter[] = [];
|
|
303
|
+
for (const name of names) {
|
|
304
|
+
const result = await this.run(["ssm", "get-parameter", "--name", name, "--with-decryption"]);
|
|
305
|
+
if (result.status !== 0) {
|
|
306
|
+
// Not tagged for this identity (or gone between the two calls):
|
|
307
|
+
// invisible, not an error — the listing is what the lease may read.
|
|
308
|
+
if (isAccessDenied(result) || isNotFound(result)) continue;
|
|
309
|
+
throw failure("get-parameter", result);
|
|
310
|
+
}
|
|
311
|
+
const value = (JSON.parse(result.stdout) as { Parameter?: { Value?: unknown } }).Parameter?.Value;
|
|
312
|
+
if (typeof value === "string") out.push({ name, value });
|
|
313
|
+
}
|
|
314
|
+
return out;
|
|
315
|
+
}
|
|
316
|
+
|
|
246
317
|
async listVersions(path: string): Promise<Array<{ name: string; version: number }>> {
|
|
247
318
|
if (!isParameterName(path)) throw new Error(`invalid parameter path: ${path}`);
|
|
248
319
|
const out: Array<{ name: string; version: number }> = [];
|
package/lib/host-env.ts
CHANGED
|
@@ -14,13 +14,14 @@
|
|
|
14
14
|
* sealed value as "<ct-base64>.<nonce-base64>".
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
|
-
import { and, eq } from "drizzle-orm";
|
|
17
|
+
import { and, eq, isNotNull } from "drizzle-orm";
|
|
18
18
|
|
|
19
19
|
import {
|
|
20
20
|
credentialStorePrefix,
|
|
21
21
|
isParameterPathSegment,
|
|
22
22
|
parameterName,
|
|
23
23
|
queueStoreWrite,
|
|
24
|
+
type CredentialStore,
|
|
24
25
|
} from "./credential-store";
|
|
25
26
|
import { getDb, schema } from "./db";
|
|
26
27
|
import { sealAesGcm, openAesGcm } from "./secrets";
|
|
@@ -40,23 +41,111 @@ function unpackValue(valueEnc: string): string {
|
|
|
40
41
|
return openAesGcm(ct, nonce);
|
|
41
42
|
}
|
|
42
43
|
|
|
43
|
-
/** Set (insert or overwrite) a project env var, encrypting the value at rest.
|
|
44
|
+
/** Set (insert or overwrite) a project env var, encrypting the value at rest.
|
|
45
|
+
* `orgId` is the project's org when the cloud sent it (see `noteProjectOrg`). */
|
|
44
46
|
export function setProjectEnvVar(
|
|
45
47
|
projectId: string,
|
|
46
48
|
key: string,
|
|
47
49
|
value: string,
|
|
50
|
+
orgId?: string,
|
|
48
51
|
): void {
|
|
52
|
+
const now = Date.now();
|
|
49
53
|
getDb()
|
|
50
54
|
.insert(schema.hostProjectEnv)
|
|
51
|
-
.values({
|
|
55
|
+
.values({
|
|
56
|
+
projectId,
|
|
57
|
+
key,
|
|
58
|
+
valueEnc: packValue(value),
|
|
59
|
+
updatedAt: now,
|
|
60
|
+
...(orgId ? { orgId } : {}),
|
|
61
|
+
})
|
|
52
62
|
.onConflictDoUpdate({
|
|
53
63
|
target: [schema.hostProjectEnv.projectId, schema.hostProjectEnv.key],
|
|
54
|
-
set: { valueEnc: packValue(value), updatedAt:
|
|
64
|
+
set: { valueEnc: packValue(value), updatedAt: now, ...(orgId ? { orgId } : {}) },
|
|
55
65
|
})
|
|
56
66
|
.run();
|
|
67
|
+
if (orgId) noteProjectOrg(projectId, orgId, { except: key });
|
|
57
68
|
mirrorProjectEnvToStore(projectId, key);
|
|
58
69
|
}
|
|
59
70
|
|
|
71
|
+
/**
|
|
72
|
+
* The cloud told us which org owns `projectId` (an env.var.set/list frame
|
|
73
|
+
* carrying `orgId`). Every row of that project mirrored before the org was
|
|
74
|
+
* known gets the org and is re-tagged in the store, so a task machine's
|
|
75
|
+
* tag-scoped lease can read it (ADR-133). Returns the rows updated.
|
|
76
|
+
*/
|
|
77
|
+
export function noteProjectOrg(
|
|
78
|
+
projectId: string,
|
|
79
|
+
orgId: string,
|
|
80
|
+
opts: { except?: string } = {},
|
|
81
|
+
): number {
|
|
82
|
+
const rows = getDb()
|
|
83
|
+
.select({ key: schema.hostProjectEnv.key, orgId: schema.hostProjectEnv.orgId, storeRef: schema.hostProjectEnv.storeRef })
|
|
84
|
+
.from(schema.hostProjectEnv)
|
|
85
|
+
.where(eq(schema.hostProjectEnv.projectId, projectId))
|
|
86
|
+
.all()
|
|
87
|
+
.filter((row) => row.key !== opts.except && row.orgId !== orgId);
|
|
88
|
+
for (const row of rows) {
|
|
89
|
+
getDb()
|
|
90
|
+
.update(schema.hostProjectEnv)
|
|
91
|
+
.set({ orgId })
|
|
92
|
+
.where(and(eq(schema.hostProjectEnv.projectId, projectId), eq(schema.hostProjectEnv.key, row.key)))
|
|
93
|
+
.run();
|
|
94
|
+
if (row.storeRef) {
|
|
95
|
+
const name = row.storeRef;
|
|
96
|
+
queueStoreWrite(`tag project env ${projectId}/${row.key}`, async (store) => {
|
|
97
|
+
await tagProjectEnvParameter(store, name, projectId, orgId);
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return rows.length;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** The org recorded for a project, or null while the cloud has not said. */
|
|
105
|
+
export function projectEnvOrgId(projectId: string): string | null {
|
|
106
|
+
const row = getDb()
|
|
107
|
+
.select({ orgId: schema.hostProjectEnv.orgId })
|
|
108
|
+
.from(schema.hostProjectEnv)
|
|
109
|
+
.where(and(eq(schema.hostProjectEnv.projectId, projectId), isNotNull(schema.hostProjectEnv.orgId)))
|
|
110
|
+
.get();
|
|
111
|
+
return row?.orgId ?? null;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** The tags a project env parameter carries: the lease policy's `org`, plus
|
|
115
|
+
* `project` for operators reading the store. */
|
|
116
|
+
export function projectEnvTags(projectId: string, orgId: string): Record<string, string> {
|
|
117
|
+
return { org: orgId, project: projectId };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const tagWarned = new Set<string>();
|
|
121
|
+
/**
|
|
122
|
+
* Tag a mirrored parameter for the task-machine lease. Best-effort: the
|
|
123
|
+
* value is already in the store, and a host whose policy predates
|
|
124
|
+
* `ssm:AddTagsToResource` (host-agent/scripts/credential-store/
|
|
125
|
+
* bootstrap-aws.sh) must keep mirroring; it warns once per parameter and a
|
|
126
|
+
* later set/list retries.
|
|
127
|
+
*/
|
|
128
|
+
export async function tagProjectEnvParameter(
|
|
129
|
+
store: Pick<CredentialStore, "tag">,
|
|
130
|
+
name: string,
|
|
131
|
+
projectId: string,
|
|
132
|
+
orgId: string,
|
|
133
|
+
): Promise<boolean> {
|
|
134
|
+
try {
|
|
135
|
+
await store.tag(name, projectEnvTags(projectId, orgId));
|
|
136
|
+
tagWarned.delete(name);
|
|
137
|
+
return true;
|
|
138
|
+
} catch (err) {
|
|
139
|
+
if (!tagWarned.has(name)) {
|
|
140
|
+
tagWarned.add(name);
|
|
141
|
+
console.warn(
|
|
142
|
+
`[project-env] credential store: tagging ${name} failed (${err instanceof Error ? err.message : String(err)}) — a task machine cannot read it until it carries org=${orgId}; re-run bootstrap-aws.sh for ssm:AddTagsToResource`,
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
return false;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
60
149
|
/** Remove a project env var (idempotent). */
|
|
61
150
|
export function deleteProjectEnvVar(projectId: string, key: string): void {
|
|
62
151
|
const result = getDb()
|
|
@@ -87,6 +176,9 @@ export interface StoredProjectEnv {
|
|
|
87
176
|
key: string;
|
|
88
177
|
value: string;
|
|
89
178
|
updatedAt: number;
|
|
179
|
+
/** The project's org once the cloud has said (ADR-133 tag scope); a
|
|
180
|
+
* replacement host inherits it with the value. */
|
|
181
|
+
orgId?: string;
|
|
90
182
|
}
|
|
91
183
|
|
|
92
184
|
/** Null when the project id or key cannot be a parameter path segment (a
|
|
@@ -111,6 +203,7 @@ export function projectEnvStoreValue(projectId: string, key: string): StoredProj
|
|
|
111
203
|
key,
|
|
112
204
|
value: unpackValue(row.valueEnc),
|
|
113
205
|
updatedAt: row.updatedAt,
|
|
206
|
+
...(row.orgId ? { orgId: row.orgId } : {}),
|
|
114
207
|
};
|
|
115
208
|
}
|
|
116
209
|
|
|
@@ -141,6 +234,7 @@ export function mirrorProjectEnvToStore(projectId: string, key: string): void {
|
|
|
141
234
|
if (!value) return;
|
|
142
235
|
await store.put(name, JSON.stringify(value));
|
|
143
236
|
setProjectEnvStoreRef(projectId, key, name);
|
|
237
|
+
if (value.orgId) await tagProjectEnvParameter(store, name, projectId, value.orgId);
|
|
144
238
|
});
|
|
145
239
|
}
|
|
146
240
|
|
|
@@ -154,6 +248,7 @@ export function importProjectEnvFromStore(value: StoredProjectEnv, storeRef: str
|
|
|
154
248
|
valueEnc: packValue(value.value),
|
|
155
249
|
updatedAt: value.updatedAt,
|
|
156
250
|
storeRef,
|
|
251
|
+
...(value.orgId ? { orgId: value.orgId } : {}),
|
|
157
252
|
})
|
|
158
253
|
.onConflictDoNothing({ target: [schema.hostProjectEnv.projectId, schema.hostProjectEnv.key] })
|
|
159
254
|
.run();
|
package/lib/shared-files-s3.ts
CHANGED
|
@@ -145,8 +145,15 @@ export async function mountSharedFilesFromLease(
|
|
|
145
145
|
argv: [
|
|
146
146
|
"/bin/sh",
|
|
147
147
|
"-c",
|
|
148
|
-
// Idempotent:
|
|
149
|
-
|
|
148
|
+
// Idempotent: a healthy mount is left alone. A mount whose daemon is
|
|
149
|
+
// gone still shows as mounted but every access fails with "Transport
|
|
150
|
+
// endpoint is not connected" (a sidecar restart SIGKILLs mount-s3 —
|
|
151
|
+
// live 2026-09-10, smoke 10); it is lazily unmounted and mounted
|
|
152
|
+
// again. The mkdir/chown tolerate the broken mount too.
|
|
153
|
+
'mkdir -p "$1" 2>/dev/null; chown "$2:$3" "$1" 2>/dev/null; ' +
|
|
154
|
+
'if mountpoint -q "$1" 2>/dev/null; then if ls "$1" >/dev/null 2>&1; then exit 0; fi; ' +
|
|
155
|
+
'umount -l "$1" 2>/dev/null || true; chown "$2:$3" "$1" 2>/dev/null; fi; ' +
|
|
156
|
+
'shift 3; exec "$@"',
|
|
150
157
|
"mount",
|
|
151
158
|
plan.directory,
|
|
152
159
|
String(owner.uid),
|
|
@@ -168,9 +175,11 @@ export async function mountSharedFilesFromLease(
|
|
|
168
175
|
}
|
|
169
176
|
if (mountedAny) {
|
|
170
177
|
// The same marker the container and machine paths write: it gates the
|
|
171
|
-
// "## Shared files" briefing in every agent's preamble.
|
|
178
|
+
// "## Shared files" briefing in every agent's preamble. The sidecar
|
|
179
|
+
// writes the MODE into it so a restart can mount again the same way
|
|
180
|
+
// (lib/sidecar-resume.ts); readers only test for the file.
|
|
172
181
|
await handle
|
|
173
|
-
.writeWorkspaceFile(`${workspacePath}/.uai/files-mounted`, Buffer.from(
|
|
182
|
+
.writeWorkspaceFile(`${workspacePath}/.uai/files-mounted`, Buffer.from(mode))
|
|
174
183
|
.catch(() => {});
|
|
175
184
|
}
|
|
176
185
|
return warnings.length > 0 ? warnings.join("\n") : null;
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ADR-133: what a sidecar re-asserts when it starts on a machine whose task
|
|
3
|
+
* is ALREADY up — a restart of the unit, not a fresh launch.
|
|
4
|
+
*
|
|
5
|
+
* Task-up mounts the shared files and launches the editor once; nothing ran
|
|
6
|
+
* them again when the runner came back, so an operator's restart (smoke 10,
|
|
7
|
+
* 2026-09-10) left /workspace/files as two dead endpoints and the Editor tab
|
|
8
|
+
* empty for the rest of the task. The unit now keeps those processes alive
|
|
9
|
+
* across a stop (KillMode=process), and this pass covers what that cannot:
|
|
10
|
+
* a crash, an older unit, a SIGKILL. Both steps are idempotent — a healthy
|
|
11
|
+
* mount and a live code-server are left alone — so running it on every
|
|
12
|
+
* lease-ready costs one stat each.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { eq } from "drizzle-orm";
|
|
16
|
+
|
|
17
|
+
import * as schema from "../db/schema";
|
|
18
|
+
import { getDb } from "./db";
|
|
19
|
+
import { mountSharedFilesFromLease } from "./shared-files-s3";
|
|
20
|
+
import { sidecarTaskId } from "./sidecar";
|
|
21
|
+
import { reconstructPersistedTaskEnvironment } from "./task-environment";
|
|
22
|
+
import { ensureCodeServer } from "./task-environment/machine-task-up";
|
|
23
|
+
import type { TaskEnvironmentHandle } from "./task-environment/types";
|
|
24
|
+
|
|
25
|
+
type PersistedTask = typeof schema.hostTasks.$inferSelect;
|
|
26
|
+
|
|
27
|
+
export interface SidecarResumeDeps {
|
|
28
|
+
taskId?: () => string | null;
|
|
29
|
+
persistedTask?: (taskId: string) => PersistedTask | undefined;
|
|
30
|
+
reconstruct?: (task: PersistedTask) => Promise<TaskEnvironmentHandle<unknown> | null>;
|
|
31
|
+
mount?: (
|
|
32
|
+
handle: TaskEnvironmentHandle<unknown>,
|
|
33
|
+
workspacePath: string,
|
|
34
|
+
task: { sharedFiles?: string },
|
|
35
|
+
) => Promise<string | null>;
|
|
36
|
+
editor?: (handle: TaskEnvironmentHandle<unknown>, workspacePath: string) => Promise<void>;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface SidecarResumeResult {
|
|
40
|
+
/** `no-task`: not a sidecar. `not-up`: the task has not been provisioned on
|
|
41
|
+
* this machine yet (a fresh launch — task-up does the work). `resumed`: the
|
|
42
|
+
* task was up before this runner started; its mounts and editor were
|
|
43
|
+
* re-asserted, with any per-step warning listed. */
|
|
44
|
+
outcome: "no-task" | "not-up" | "resumed";
|
|
45
|
+
warnings: string[];
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** The mode task-up mounted with, recorded in the files-mounted marker; null
|
|
49
|
+
* when the files were never mounted (mode off, or the mount failed then). */
|
|
50
|
+
function mountedModeFromMarker(bytes: Uint8Array | null): "ro" | "rw" | null {
|
|
51
|
+
if (bytes === null) return null;
|
|
52
|
+
return Buffer.from(bytes).toString("utf8").trim() === "rw" ? "rw" : "ro";
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export async function resumeSidecarTask(
|
|
56
|
+
deps: SidecarResumeDeps = {},
|
|
57
|
+
): Promise<SidecarResumeResult> {
|
|
58
|
+
const taskId = (deps.taskId ?? sidecarTaskId)();
|
|
59
|
+
if (!taskId) return { outcome: "no-task", warnings: [] };
|
|
60
|
+
const task = (deps.persistedTask ?? defaultPersistedTask)(taskId);
|
|
61
|
+
if (!task?.environmentLocator) return { outcome: "not-up", warnings: [] };
|
|
62
|
+
const handle = await (deps.reconstruct ?? reconstructPersistedTaskEnvironment)(task);
|
|
63
|
+
if (!handle) return { outcome: "not-up", warnings: [] };
|
|
64
|
+
const workspacePath = handle.descriptor.workspacePath;
|
|
65
|
+
const warnings: string[] = [];
|
|
66
|
+
|
|
67
|
+
const marker = await handle
|
|
68
|
+
.readWorkspaceFile(`${workspacePath}/.uai/files-mounted`)
|
|
69
|
+
.catch(() => null);
|
|
70
|
+
const mode = mountedModeFromMarker(marker);
|
|
71
|
+
if (mode) {
|
|
72
|
+
try {
|
|
73
|
+
const warning = await (deps.mount ?? mountSharedFilesFromLease)(handle, workspacePath, {
|
|
74
|
+
sharedFiles: mode,
|
|
75
|
+
});
|
|
76
|
+
if (warning) warnings.push(warning);
|
|
77
|
+
} catch (error) {
|
|
78
|
+
warnings.push(
|
|
79
|
+
`shared files: ${error instanceof Error ? error.message : String(error)}`,
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
try {
|
|
85
|
+
await (deps.editor ?? defaultEditor)(handle, workspacePath);
|
|
86
|
+
} catch (error) {
|
|
87
|
+
warnings.push(`editor: ${error instanceof Error ? error.message : String(error)}`);
|
|
88
|
+
}
|
|
89
|
+
return { outcome: "resumed", warnings };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function defaultPersistedTask(taskId: string): PersistedTask | undefined {
|
|
93
|
+
return getDb()
|
|
94
|
+
.select()
|
|
95
|
+
.from(schema.hostTasks)
|
|
96
|
+
.where(eq(schema.hostTasks.taskId, taskId))
|
|
97
|
+
.get();
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function defaultEditor(
|
|
101
|
+
handle: TaskEnvironmentHandle<unknown>,
|
|
102
|
+
workspacePath: string,
|
|
103
|
+
): Promise<void> {
|
|
104
|
+
// The theme rode task-up's preview env and is not persisted; the settings
|
|
105
|
+
// seed is skipped when the file already exists, so an existing task keeps
|
|
106
|
+
// the theme it started with.
|
|
107
|
+
return ensureCodeServer(handle, workspacePath, undefined);
|
|
108
|
+
}
|
package/lib/skills.ts
CHANGED
|
@@ -203,6 +203,34 @@ const dockerExec: SkillExec = async (container, cmd, cwd) => {
|
|
|
203
203
|
return { status: res.status, stderr: res.stderr };
|
|
204
204
|
};
|
|
205
205
|
|
|
206
|
+
/**
|
|
207
|
+
* A `skills add …` install command (github.com/vercel-labs/skills, the CLI
|
|
208
|
+
* behind the skills marketplace) asks which agents to install for and for a
|
|
209
|
+
* confirmation unless an agent is detected or `-y`/`--agent` are given. The
|
|
210
|
+
* install runs at task-up with no TTY and, on a fresh machine, before any
|
|
211
|
+
* agent has left its footprint, so the CLI refused: "Interactive prompt
|
|
212
|
+
* required but stdin is not a TTY … Use --agent <name> and -y" (every task
|
|
213
|
+
* start since the Marketing persona gained the skill; live 2026-09-10).
|
|
214
|
+
* Insert the two flags right after `skills add` when the author left them
|
|
215
|
+
* out — after the subcommand, so a trailing `&& …` or a leading `cd …`
|
|
216
|
+
* keeps working. Any other install command is returned as written.
|
|
217
|
+
*/
|
|
218
|
+
export function nonInteractiveInstallCommand(command: string): string {
|
|
219
|
+
const add = /\bskills\s+add\b/;
|
|
220
|
+
const found = add.exec(command);
|
|
221
|
+
if (!found) return command;
|
|
222
|
+
// Only the flags AFTER the subcommand count: `npx -y skills add …` is npx's
|
|
223
|
+
// own -y, not the CLI's.
|
|
224
|
+
const rest = command.slice(found.index + found[0].length);
|
|
225
|
+
const flags: string[] = [];
|
|
226
|
+
if (!/(^|\s)(-y|--yes)(\s|$)/.test(rest)) flags.push("-y");
|
|
227
|
+
if (!/(^|\s)(-a|--agent)(\s|=)/.test(rest)) flags.push("--agent claude-code");
|
|
228
|
+
if (flags.length === 0) return command;
|
|
229
|
+
return (
|
|
230
|
+
command.slice(0, found.index + found[0].length) + ` ${flags.join(" ")}` + rest
|
|
231
|
+
);
|
|
232
|
+
}
|
|
233
|
+
|
|
206
234
|
/**
|
|
207
235
|
* Install a roster's package skills into the task container (ADR-047). Runs at
|
|
208
236
|
* task-up (after the container is running), mirroring the git-identity/github
|
|
@@ -266,7 +294,11 @@ export async function installPackageSkills(
|
|
|
266
294
|
continue;
|
|
267
295
|
}
|
|
268
296
|
// The author's CLI drops files into .claude/skills; run it in the repo.
|
|
269
|
-
const c = await exec(
|
|
297
|
+
const c = await exec(
|
|
298
|
+
container,
|
|
299
|
+
["bash", "-lc", nonInteractiveInstallCommand(s.value)],
|
|
300
|
+
"/workspace",
|
|
301
|
+
);
|
|
270
302
|
if (c.status !== 0) {
|
|
271
303
|
console.warn(`[skills] ${taskId}: install cmd for ${s.name} failed: ${c.stderr.trim()}`);
|
|
272
304
|
continue;
|
|
@@ -388,7 +388,7 @@ async function copySharedFiles(
|
|
|
388
388
|
/** Launch code-server for the Editor pane, exactly once. The pgrep guard
|
|
389
389
|
* makes re-provision and resume idempotent; the exec chain leaves only
|
|
390
390
|
* code-server holding the detached job. */
|
|
391
|
-
async function ensureCodeServer(
|
|
391
|
+
export async function ensureCodeServer(
|
|
392
392
|
handle: TaskEnvironmentHandle<unknown>,
|
|
393
393
|
workspacePath: string,
|
|
394
394
|
editorTheme: string | undefined,
|
|
@@ -23,7 +23,8 @@ import {
|
|
|
23
23
|
isParameterPathSegment,
|
|
24
24
|
type CredentialStore,
|
|
25
25
|
} from "./credential-store";
|
|
26
|
-
import {
|
|
26
|
+
import { projectEnvListPaths,
|
|
27
|
+
projectEnvListsByName, userCredentialListPath } from "./credential-store-scope";
|
|
27
28
|
import {
|
|
28
29
|
deleteToken as deleteGithubTokenLocal,
|
|
29
30
|
githubTokenParameterName,
|
|
@@ -49,6 +50,8 @@ import {
|
|
|
49
50
|
projectEnvParameterName,
|
|
50
51
|
projectEnvStoreValue,
|
|
51
52
|
setProjectEnvStoreRef,
|
|
53
|
+
noteProjectOrg,
|
|
54
|
+
tagProjectEnvParameter,
|
|
52
55
|
type StoredProjectEnv,
|
|
53
56
|
} from "./host-env";
|
|
54
57
|
import {
|
|
@@ -347,10 +350,18 @@ export function parseStoredProjectEnv(value: string): StoredProjectEnv {
|
|
|
347
350
|
key: str(o, "key"),
|
|
348
351
|
value: str(o, "value"),
|
|
349
352
|
updatedAt: num(o, "updatedAt"),
|
|
353
|
+
...(typeof o.orgId === "string" && o.orgId.length > 0 ? { orgId: o.orgId } : {}),
|
|
350
354
|
};
|
|
351
355
|
}
|
|
352
356
|
|
|
353
|
-
|
|
357
|
+
/**
|
|
358
|
+
* Host start (whole tree) or, on a task machine, task-up with the task's
|
|
359
|
+
* project ids (ADR-133: the lease reads `/uai/projects/<id>` only for
|
|
360
|
+
* parameters tagged with its org, and only the task's projects matter).
|
|
361
|
+
*/
|
|
362
|
+
export async function reconcileProjectEnvFromStore(
|
|
363
|
+
opts: { projectIds?: readonly string[] } = {},
|
|
364
|
+
): Promise<ReconcileUserCredentialsResult> {
|
|
354
365
|
const result: ReconcileUserCredentialsResult = {
|
|
355
366
|
skipped: false,
|
|
356
367
|
imported: 0,
|
|
@@ -361,16 +372,20 @@ export async function reconcileProjectEnvFromStore(): Promise<ReconcileUserCrede
|
|
|
361
372
|
const store = configuredCredentialStore();
|
|
362
373
|
if (!store) return { ...result, skipped: true };
|
|
363
374
|
const prefix = credentialStorePrefix();
|
|
364
|
-
const
|
|
365
|
-
if (
|
|
375
|
+
const paths = projectEnvListPaths(prefix, opts.projectIds);
|
|
376
|
+
if (paths === null) return { ...result, skipped: true };
|
|
366
377
|
const warn = (line: string): void => console.warn(`[project-env] credential store: ${line}`);
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
378
|
+
const listed: Array<{ name: string; value: string }> = [];
|
|
379
|
+
const listedPrefixes: string[] = [];
|
|
380
|
+
const byName = projectEnvListsByName();
|
|
381
|
+
for (const path of paths) {
|
|
382
|
+
try {
|
|
383
|
+
listed.push(...(byName ? await store.listByName(path) : await store.list(path)));
|
|
384
|
+
listedPrefixes.push(`${path}/`);
|
|
385
|
+
} catch (err) {
|
|
386
|
+
result.errors++;
|
|
387
|
+
warn(`listing ${path} failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
388
|
+
}
|
|
374
389
|
}
|
|
375
390
|
const local = new Map(listProjectEnvStoreRows().map((r) => [`${r.projectId}/${r.key}`, r]));
|
|
376
391
|
const seen = new Set<string>();
|
|
@@ -386,6 +401,7 @@ export async function reconcileProjectEnvFromStore(): Promise<ReconcileUserCrede
|
|
|
386
401
|
const row = local.get(`${at.projectId}/${at.key}`);
|
|
387
402
|
if (row) {
|
|
388
403
|
if (row.storeRef !== param.name) setProjectEnvStoreRef(at.projectId, at.key, param.name);
|
|
404
|
+
if (value.orgId) noteProjectOrg(at.projectId, value.orgId);
|
|
389
405
|
} else if (importProjectEnvFromStore(value, param.name)) {
|
|
390
406
|
result.imported++;
|
|
391
407
|
}
|
|
@@ -397,7 +413,8 @@ export async function reconcileProjectEnvFromStore(): Promise<ReconcileUserCrede
|
|
|
397
413
|
for (const row of local.values()) {
|
|
398
414
|
try {
|
|
399
415
|
if (row.storeRef) {
|
|
400
|
-
|
|
416
|
+
const ref = row.storeRef;
|
|
417
|
+
if (listedPrefixes.some((p) => ref.startsWith(p)) && !seen.has(ref)) {
|
|
401
418
|
deleteProjectEnvLocal(row.projectId, row.key);
|
|
402
419
|
result.removed++;
|
|
403
420
|
}
|
|
@@ -408,6 +425,7 @@ export async function reconcileProjectEnvFromStore(): Promise<ReconcileUserCrede
|
|
|
408
425
|
if (!name || !value) continue;
|
|
409
426
|
await upload(store, name, value, (ref) => setProjectEnvStoreRef(row.projectId, row.key, ref));
|
|
410
427
|
result.uploaded++;
|
|
428
|
+
if (value.orgId) await tagProjectEnvParameter(store, name, row.projectId, value.orgId);
|
|
411
429
|
} catch (err) {
|
|
412
430
|
result.errors++;
|
|
413
431
|
warn(`${row.projectId}/${row.key}: ${err instanceof Error ? err.message : String(err)}`);
|
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -201,11 +201,32 @@ export function setManagedHostRestartHook(hook: () => void): void {
|
|
|
201
201
|
managedHostRestartHook = hook;
|
|
202
202
|
}
|
|
203
203
|
|
|
204
|
+
/**
|
|
205
|
+
* ADR-133: a task machine's runner is pinned by the cloud at launch and its
|
|
206
|
+
* unit's cgroup holds the task itself — the S3 mounts, the display, the
|
|
207
|
+
* editor, the agent sessions. An in-place update or restart replaces
|
|
208
|
+
* nothing (the prepare step reinstalls the pin) and killed all of those
|
|
209
|
+
* (live 2026-09-10, smoke 10). Both verbs are refused here; a new task gets
|
|
210
|
+
* a fresh machine with the current runner.
|
|
211
|
+
*/
|
|
212
|
+
function sidecarRunnerIsPinned(): HostCommandResult<never> | null {
|
|
213
|
+
if (!sidecarMode()) return null;
|
|
214
|
+
return {
|
|
215
|
+
ok: false,
|
|
216
|
+
code: HostErrorCode.Internal,
|
|
217
|
+
message:
|
|
218
|
+
"a task machine's runner is pinned for the task's life — it neither updates nor restarts in place; close the task and start another for the current runner",
|
|
219
|
+
retryable: false,
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
|
|
204
223
|
export const hostCommands: HostCommands = {
|
|
205
224
|
// The hostId argument is cloud-side routing (host-scoped commands carry
|
|
206
225
|
// their target in args, like filesOp); this process IS the target.
|
|
207
226
|
async hostUpdate(ctx, _hostId) {
|
|
208
227
|
logCommand(ctx, "hostUpdate");
|
|
228
|
+
const pinned = sidecarRunnerIsPinned();
|
|
229
|
+
if (pinned) return pinned;
|
|
209
230
|
try {
|
|
210
231
|
const { updateManagedRuntime } = await import("../lib/managed-runtime");
|
|
211
232
|
const outcome = await updateManagedRuntime();
|
|
@@ -244,6 +265,8 @@ export const hostCommands: HostCommands = {
|
|
|
244
265
|
},
|
|
245
266
|
async hostRestart(ctx, _hostId) {
|
|
246
267
|
logCommand(ctx, "hostRestart");
|
|
268
|
+
const pinned = sidecarRunnerIsPinned();
|
|
269
|
+
if (pinned) return pinned;
|
|
247
270
|
if (!managedHostRestartHook) {
|
|
248
271
|
return {
|
|
249
272
|
ok: false,
|
|
@@ -828,6 +851,25 @@ export const hostCommands: HostCommands = {
|
|
|
828
851
|
// ADR-133 sidecar: nothing to clone or spawn with until the lease
|
|
829
852
|
// has landed and the store is reconciled under it.
|
|
830
853
|
await awaitSidecarReady();
|
|
854
|
+
if (sidecarMode()) {
|
|
855
|
+
// ADR-133: project env lives under /uai/projects/<id>, readable by
|
|
856
|
+
// the lease only for parameters tagged with this org, and only the
|
|
857
|
+
// task's own projects are worth listing — their ids are known here,
|
|
858
|
+
// not at the first grant (docs/credential-store.md).
|
|
859
|
+
const { reconcileProjectEnvFromStore } = await import("../lib/user-credentials-store");
|
|
860
|
+
const projectIds = input.projects.map((project) => project.id);
|
|
861
|
+
const env = await reconcileProjectEnvFromStore({ projectIds }).catch((error: unknown) => {
|
|
862
|
+
console.warn(
|
|
863
|
+
`[project-env] task ${input.task.id}: credential store read failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
864
|
+
);
|
|
865
|
+
return null;
|
|
866
|
+
});
|
|
867
|
+
if (env && !env.skipped) {
|
|
868
|
+
console.log(
|
|
869
|
+
`[project-env] task ${input.task.id}: credential store reconciled for ${projectIds.length} project(s): ${env.imported} imported, ${env.removed} removed, ${env.errors} errors`,
|
|
870
|
+
);
|
|
871
|
+
}
|
|
872
|
+
}
|
|
831
873
|
// Launch screen: the first stage boundary. A machine-backed task
|
|
832
874
|
// spends the next minute launching an EC2 instance; a container
|
|
833
875
|
// task goes straight to preparing its workspace.
|
package/src/main.ts
CHANGED
|
@@ -72,6 +72,7 @@ import {
|
|
|
72
72
|
deleteProjectEnvVar,
|
|
73
73
|
listProjectEnvKeys,
|
|
74
74
|
setProjectEnvVar,
|
|
75
|
+
noteProjectOrg,
|
|
75
76
|
} from "../lib/host-env";
|
|
76
77
|
import { handleMcpOp } from "../lib/mcp-connections";
|
|
77
78
|
import { startMcpGateway } from "../lib/mcp-gateway";
|
|
@@ -252,6 +253,7 @@ import {
|
|
|
252
253
|
import { isEnvironmentTransportProvider } from "../lib/task-environment/types";
|
|
253
254
|
import { LeaseManager } from "../lib/lease";
|
|
254
255
|
import { markSidecarReady, sidecarMode, sidecarTaskId } from "../lib/sidecar";
|
|
256
|
+
import { resumeSidecarTask } from "../lib/sidecar-resume";
|
|
255
257
|
import { declareAgentClisReady } from "../lib/standard-image";
|
|
256
258
|
|
|
257
259
|
setContainerRuntimeOperationalCheck(() => containerRuntimeProblem() === null);
|
|
@@ -552,6 +554,21 @@ const leaseManager: LeaseManager | null = sidecarMode()
|
|
|
552
554
|
// tokens, and project env on the machine (lib/sidecar.ts).
|
|
553
555
|
markSidecarReady();
|
|
554
556
|
console.log("[lease] credential store reconciled — the task may start");
|
|
557
|
+
// A runner that came back on a machine whose task is already up
|
|
558
|
+
// re-asserts the mounts and the editor (lib/sidecar-resume.ts).
|
|
559
|
+
void resumeSidecarTask()
|
|
560
|
+
.then((resumed) => {
|
|
561
|
+
if (resumed.outcome !== "resumed") return;
|
|
562
|
+
console.log(
|
|
563
|
+
`[sidecar] task ${sidecarTaskId()} resumed after a runner restart: shared files and editor re-asserted` +
|
|
564
|
+
(resumed.warnings.length > 0 ? ` (${resumed.warnings.join("; ")})` : ""),
|
|
565
|
+
);
|
|
566
|
+
})
|
|
567
|
+
.catch((error: unknown) => {
|
|
568
|
+
console.warn(
|
|
569
|
+
`[sidecar] resume failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
570
|
+
);
|
|
571
|
+
});
|
|
555
572
|
});
|
|
556
573
|
startCredentialStorePickup({ repush: repushBrokeredCopies });
|
|
557
574
|
},
|
|
@@ -1816,9 +1833,16 @@ async function connect(): Promise<void> {
|
|
|
1816
1833
|
case "env.var.delete": {
|
|
1817
1834
|
try {
|
|
1818
1835
|
if (frame.kind === "env.var.set") {
|
|
1819
|
-
setProjectEnvVar(frame.projectId, frame.key, frame.value);
|
|
1836
|
+
setProjectEnvVar(frame.projectId, frame.key, frame.value, frame.orgId);
|
|
1820
1837
|
} else if (frame.kind === "env.var.delete") {
|
|
1821
1838
|
deleteProjectEnvVar(frame.projectId, frame.key);
|
|
1839
|
+
} else if (frame.orgId) {
|
|
1840
|
+
// A list that names the org backfills rows mirrored before the
|
|
1841
|
+
// cloud sent it, and re-tags them for the task-machine lease.
|
|
1842
|
+
const updated = noteProjectOrg(frame.projectId, frame.orgId);
|
|
1843
|
+
if (updated > 0) {
|
|
1844
|
+
console.log(`[project-env] ${frame.projectId}: org recorded on ${updated} row(s); re-tagging`);
|
|
1845
|
+
}
|
|
1822
1846
|
}
|
|
1823
1847
|
// The ack carries the project's KEY NAMES only — values never cross
|
|
1824
1848
|
// the bridge.
|
package/src/protocol.ts
CHANGED
|
@@ -1657,13 +1657,18 @@ export type CloudToHost =
|
|
|
1657
1657
|
// (env.var.ack) — the value on `env.var.set` is write-only (never echoed
|
|
1658
1658
|
// back). `opId` correlates request↔ack; the ack's `keys` are that project's
|
|
1659
1659
|
// keys.
|
|
1660
|
-
|
|
1660
|
+
// `orgId` (cloud from 2026-09-10): the project's org, so the host tags the
|
|
1661
|
+
// mirrored parameter for the tag-scoped task-machine lease policy (ADR-133,
|
|
1662
|
+
// docs/credential-store.md). A list carrying it also backfills rows that
|
|
1663
|
+
// were mirrored before the org was known. Absent from an older cloud.
|
|
1664
|
+
| { kind: "env.var.list"; opId: string; projectId: string; orgId?: string }
|
|
1661
1665
|
| {
|
|
1662
1666
|
kind: "env.var.set";
|
|
1663
1667
|
opId: string;
|
|
1664
1668
|
projectId: string;
|
|
1665
1669
|
key: string;
|
|
1666
1670
|
value: string;
|
|
1671
|
+
orgId?: string;
|
|
1667
1672
|
}
|
|
1668
1673
|
| { kind: "env.var.delete"; opId: string; projectId: string; key: string }
|
|
1669
1674
|
// User-authed MCP connections (ADR-057). Ops run entirely host-side —
|